feat: receipt customization settings tab with header, footer, policy, social

- New Receipt tab in Settings page with editable fields
- receipt_header: text below logo (e.g. tagline)
- receipt_footer: thank you message
- receipt_return_policy: return policy text
- receipt_social: website/social media
- All stored in app_config, rendered on printed receipts
- Seeded in migration with empty defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-04 21:28:37 +00:00
parent 0aa9345c27
commit 8820a56a51
4 changed files with 157 additions and 8 deletions

View File

@@ -63,6 +63,20 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
const QUICK_AMOUNTS = [1, 5, 10, 20, 50, 100]
// Fetch receipt config
interface AppConfigEntry { key: string; value: string | null }
const { data: configData } = useQuery({
queryKey: ['config'],
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
enabled: !!result?.id,
})
const receiptConfig = {
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
}
// Fetch full receipt data after completion
const { data: receiptData } = useQuery({
queryKey: ['pos', 'receipt', result?.id],
@@ -91,7 +105,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
</Button>
</div>
<div className="print:block">
<POSReceipt data={receiptData} footerText="Thank you for your business!" />
<POSReceipt data={receiptData} config={receiptConfig} />
</div>
</DialogContent>
</Dialog>

View File

@@ -45,10 +45,18 @@ interface ReceiptData {
}
}
interface ReceiptConfig {
header?: string
footer?: string
returnPolicy?: string
social?: string
}
interface POSReceiptProps {
data: ReceiptData
size?: 'thermal' | 'full'
footerText?: string
config?: ReceiptConfig
}
function useStoreLogo(companyId?: string) {
@@ -91,7 +99,7 @@ function useStoreLogo(companyId?: string) {
return logoSrc
}
export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptProps) {
export function POSReceipt({ data, size = 'thermal', footerText, config }: POSReceiptProps) {
const barcodeRef = useRef<SVGSVGElement>(null)
const { transaction: txn, company, location } = data
const isThermal = size === 'thermal'
@@ -146,6 +154,7 @@ export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptPro
</>
)}
{phone && <div>{phone}</div>}
{config?.header && <div className="mt-1 text-gray-600">{config.header}</div>}
</div>
{/* Transaction info */}
@@ -234,8 +243,14 @@ export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptPro
</div>
{/* Footer */}
{footerText && (
<div className="text-center text-gray-500 pb-2">{footerText}</div>
{(config?.footer || footerText) && (
<div className="text-center text-gray-500 pb-1">{config?.footer || footerText}</div>
)}
{config?.returnPolicy && (
<div className="text-center text-gray-400 text-[10px] pb-1">{config.returnPolicy}</div>
)}
{config?.social && (
<div className="text-center text-gray-500 pb-2">{config.social}</div>
)}
</div>
)

View File

@@ -15,7 +15,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2 } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2, Receipt } from 'lucide-react'
import { toast } from 'sonner'
interface StoreSettings {
@@ -118,6 +119,14 @@ function SettingsPage() {
<div className="space-y-6 max-w-3xl">
<h1 className="text-2xl font-bold">Settings</h1>
<Tabs defaultValue="general">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="receipt">Receipt</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6 mt-4">
{/* Store Info */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
@@ -240,6 +249,13 @@ function SettingsPage() {
{/* App Configuration */}
<AppConfigCard />
</TabsContent>
<TabsContent value="receipt" className="mt-4">
<ReceiptSettingsCard />
</TabsContent>
</Tabs>
</div>
)
}
@@ -376,6 +392,106 @@ function AppConfigCard() {
)
}
const RECEIPT_FIELDS = [
{ key: 'receipt_header', label: 'Header Text', placeholder: "e.g. San Antonio's Premier String Shop", multiline: false },
{ key: 'receipt_footer', label: 'Footer Message', placeholder: 'e.g. Thank you for your business!', multiline: false },
{ key: 'receipt_return_policy', label: 'Return Policy', placeholder: 'e.g. Returns accepted within 30 days with receipt.', multiline: true },
{ key: 'receipt_social', label: 'Website / Social', placeholder: 'e.g. www.demostore.com | @demostore', multiline: false },
]
function ReceiptSettingsCard() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const canEdit = hasPermission('settings.edit')
const { data: configData, isLoading } = useQuery(configOptions())
const configs = configData?.data ?? []
const [editing, setEditing] = useState(false)
const [fields, setFields] = useState<Record<string, string>>({})
function startEdit() {
const f: Record<string, string> = {}
for (const rf of RECEIPT_FIELDS) {
f[rf.key] = configs.find((c) => c.key === rf.key)?.value ?? ''
}
setFields(f)
setEditing(true)
}
const saveMutation = useMutation({
mutationFn: async () => {
for (const rf of RECEIPT_FIELDS) {
await api.patch(`/v1/config/${rf.key}`, { value: fields[rf.key] || '' })
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['config'] })
toast.success('Receipt settings saved')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Receipt className="h-5 w-5" />Receipt Customization
</CardTitle>
{canEdit && !editing && <Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>}
{editing && (
<div className="flex gap-2">
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{saveMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-32 w-full" />
) : editing ? (
<div className="space-y-4">
{RECEIPT_FIELDS.map((rf) => (
<div key={rf.key} className="space-y-2">
<Label>{rf.label}</Label>
{rf.multiline ? (
<Textarea
value={fields[rf.key] ?? ''}
onChange={(e) => setFields((p) => ({ ...p, [rf.key]: e.target.value }))}
placeholder={rf.placeholder}
rows={2}
/>
) : (
<Input
value={fields[rf.key] ?? ''}
onChange={(e) => setFields((p) => ({ ...p, [rf.key]: e.target.value }))}
placeholder={rf.placeholder}
/>
)}
</div>
))}
</div>
) : (
<div className="space-y-3">
{RECEIPT_FIELDS.map((rf) => {
const value = configs.find((c) => c.key === rf.key)?.value
return (
<div key={rf.key} className="flex justify-between items-start">
<span className="text-sm text-muted-foreground">{rf.label}</span>
<span className="text-sm text-right max-w-[60%]">{value || <span className="text-muted-foreground/50">Not set</span>}</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
function LocationCard({ location }: { location: Location }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)

View File

@@ -10,7 +10,11 @@ BEGIN
END LOOP;
END $$;
-- Seed POS lock timeout config
INSERT INTO "app_config" ("key", "value", "description")
VALUES ('pos_lock_timeout', '15', 'POS auto-lock timeout in minutes (0 to disable)')
-- Seed POS config
INSERT INTO "app_config" ("key", "value", "description") VALUES
('pos_lock_timeout', '15', 'POS auto-lock timeout in minutes (0 to disable)'),
('receipt_header', '', 'Text shown below logo on receipts'),
('receipt_footer', '', 'Thank you message at bottom of receipt'),
('receipt_return_policy', '', 'Return policy text on receipt (blank to hide)'),
('receipt_social', '', 'Website or social media shown on receipt')
ON CONFLICT ("key") DO NOTHING;