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]
|
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
|
// Fetch full receipt data after completion
|
||||||
const { data: receiptData } = useQuery({
|
const { data: receiptData } = useQuery({
|
||||||
queryKey: ['pos', 'receipt', result?.id],
|
queryKey: ['pos', 'receipt', result?.id],
|
||||||
@@ -91,7 +105,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="print:block">
|
<div className="print:block">
|
||||||
<POSReceipt data={receiptData} footerText="Thank you for your business!" />
|
<POSReceipt data={receiptData} config={receiptConfig} />
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -45,10 +45,18 @@ interface ReceiptData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReceiptConfig {
|
||||||
|
header?: string
|
||||||
|
footer?: string
|
||||||
|
returnPolicy?: string
|
||||||
|
social?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface POSReceiptProps {
|
interface POSReceiptProps {
|
||||||
data: ReceiptData
|
data: ReceiptData
|
||||||
size?: 'thermal' | 'full'
|
size?: 'thermal' | 'full'
|
||||||
footerText?: string
|
footerText?: string
|
||||||
|
config?: ReceiptConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
function useStoreLogo(companyId?: string) {
|
function useStoreLogo(companyId?: string) {
|
||||||
@@ -91,7 +99,7 @@ function useStoreLogo(companyId?: string) {
|
|||||||
return logoSrc
|
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 barcodeRef = useRef<SVGSVGElement>(null)
|
||||||
const { transaction: txn, company, location } = data
|
const { transaction: txn, company, location } = data
|
||||||
const isThermal = size === 'thermal'
|
const isThermal = size === 'thermal'
|
||||||
@@ -146,6 +154,7 @@ export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptPro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{phone && <div>{phone}</div>}
|
{phone && <div>{phone}</div>}
|
||||||
|
{config?.header && <div className="mt-1 text-gray-600">{config.header}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction info */}
|
{/* Transaction info */}
|
||||||
@@ -234,8 +243,14 @@ export function POSReceipt({ data, size = 'thermal', footerText }: POSReceiptPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{footerText && (
|
{(config?.footer || footerText) && (
|
||||||
<div className="text-center text-gray-500 pb-2">{footerText}</div>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
|
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'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
interface StoreSettings {
|
interface StoreSettings {
|
||||||
@@ -118,6 +119,14 @@ function SettingsPage() {
|
|||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<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 */}
|
{/* Store Info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
@@ -240,6 +249,13 @@ function SettingsPage() {
|
|||||||
|
|
||||||
{/* App Configuration */}
|
{/* App Configuration */}
|
||||||
<AppConfigCard />
|
<AppConfigCard />
|
||||||
|
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="receipt" className="mt-4">
|
||||||
|
<ReceiptSettingsCard />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</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 }) {
|
function LocationCard({ location }: { location: Location }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ BEGIN
|
|||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- Seed POS lock timeout config
|
-- Seed POS config
|
||||||
INSERT INTO "app_config" ("key", "value", "description")
|
INSERT INTO "app_config" ("key", "value", "description") VALUES
|
||||||
VALUES ('pos_lock_timeout', '15', 'POS auto-lock timeout in minutes (0 to disable)')
|
('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;
|
ON CONFLICT ("key") DO NOTHING;
|
||||||
|
|||||||
Reference in New Issue
Block a user