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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user