From 8820a56a5139c2cb5347afd53e8f3a8f230e8c9c Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 4 Apr 2026 21:28:37 +0000 Subject: [PATCH] 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) --- .../src/components/pos/pos-payment-dialog.tsx | 16 ++- .../admin/src/components/pos/pos-receipt.tsx | 21 +++- .../src/routes/_authenticated/settings.tsx | 118 +++++++++++++++++- .../src/db/migrations/0042_user-pin.sql | 10 +- 4 files changed, 157 insertions(+), 8 deletions(-) diff --git a/packages/admin/src/components/pos/pos-payment-dialog.tsx b/packages/admin/src/components/pos/pos-payment-dialog.tsx index f4c7cf1..63c1339 100644 --- a/packages/admin/src/components/pos/pos-payment-dialog.tsx +++ b/packages/admin/src/components/pos/pos-payment-dialog.tsx @@ -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
- +
diff --git a/packages/admin/src/components/pos/pos-receipt.tsx b/packages/admin/src/components/pos/pos-receipt.tsx index d4f2d47..1d72266 100644 --- a/packages/admin/src/components/pos/pos-receipt.tsx +++ b/packages/admin/src/components/pos/pos-receipt.tsx @@ -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(null) const { transaction: txn, company, location } = data const isThermal = size === 'thermal' @@ -146,6 +154,7 @@ export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptPro )} {phone &&
{phone}
} + {config?.header &&
{config.header}
} {/* Transaction info */} @@ -234,8 +243,14 @@ export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptPro {/* Footer */} - {footerText && ( -
{footerText}
+ {(config?.footer || footerText) && ( +
{config?.footer || footerText}
+ )} + {config?.returnPolicy && ( +
{config.returnPolicy}
+ )} + {config?.social && ( +
{config.social}
)} ) diff --git a/packages/admin/src/routes/_authenticated/settings.tsx b/packages/admin/src/routes/_authenticated/settings.tsx index cd4ef97..54770f4 100644 --- a/packages/admin/src/routes/_authenticated/settings.tsx +++ b/packages/admin/src/routes/_authenticated/settings.tsx @@ -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() {

Settings

+ + + General + Receipt + + + + {/* Store Info */} @@ -240,6 +249,13 @@ function SettingsPage() { {/* App Configuration */} + + + + + + +
) } @@ -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>({}) + + function startEdit() { + const f: Record = {} + 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 ( + + + + Receipt Customization + + {canEdit && !editing && } + {editing && ( +
+ + +
+ )} +
+ + {isLoading ? ( + + ) : editing ? ( +
+ {RECEIPT_FIELDS.map((rf) => ( +
+ + {rf.multiline ? ( +