feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiGet, apiPatch } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
|
||||
type BranchPrintSettings = {
|
||||
branchId: string;
|
||||
receiptPrinterIp?: string | null;
|
||||
receiptPrinterPort?: number | null;
|
||||
kitchenPrinterIp?: string | null;
|
||||
kitchenPrinterPort?: number | null;
|
||||
paperWidthMm: number;
|
||||
autoCutEnabled: boolean;
|
||||
receiptHeader?: string | null;
|
||||
receiptFooter?: string | null;
|
||||
wifiPassword?: string | null;
|
||||
posDeviceIp?: string | null;
|
||||
posDevicePort?: number | null;
|
||||
};
|
||||
|
||||
type SettingsPrinterPanelProps = {
|
||||
cafeId: string;
|
||||
onOpenPrintTest?: () => void;
|
||||
};
|
||||
|
||||
export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinterPanelProps) {
|
||||
const t = useTranslations("print");
|
||||
const tSettings = useTranslations("settings");
|
||||
const tCommon = useTranslations("common");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const [receiptIp, setReceiptIp] = useState("");
|
||||
const [receiptPort, setReceiptPort] = useState("9100");
|
||||
const [kitchenIp, setKitchenIp] = useState("");
|
||||
const [kitchenPort, setKitchenPort] = useState("9100");
|
||||
const [paperWidth, setPaperWidth] = useState("80");
|
||||
const [autoCut, setAutoCut] = useState(true);
|
||||
const [receiptHeader, setReceiptHeader] = useState("");
|
||||
const [receiptFooter, setReceiptFooter] = useState("");
|
||||
const [wifiPassword, setWifiPassword] = useState("");
|
||||
const [posDeviceIp, setPosDeviceIp] = useState("");
|
||||
const [posDevicePort, setPosDevicePort] = useState("8088");
|
||||
|
||||
const { data: branches = [], isLoading: branchesLoading } = useQuery({
|
||||
queryKey: ["branches", cafeId],
|
||||
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const branchId = branches[0]?.id;
|
||||
|
||||
const { data: settings, refetch } = useQuery({
|
||||
queryKey: ["branch-print-settings", cafeId, branchId],
|
||||
queryFn: () =>
|
||||
apiGet<BranchPrintSettings>(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`
|
||||
),
|
||||
enabled: !!cafeId && !!branchId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setReceiptIp(settings.receiptPrinterIp ?? "");
|
||||
setReceiptPort(String(settings.receiptPrinterPort ?? 9100));
|
||||
setKitchenIp(settings.kitchenPrinterIp ?? "");
|
||||
setKitchenPort(String(settings.kitchenPrinterPort ?? 9100));
|
||||
setPaperWidth(String(settings.paperWidthMm === 58 ? 58 : 80));
|
||||
setAutoCut(settings.autoCutEnabled);
|
||||
setReceiptHeader(settings.receiptHeader ?? "");
|
||||
setReceiptFooter(settings.receiptFooter ?? "");
|
||||
setWifiPassword(settings.wifiPassword ?? "");
|
||||
setPosDeviceIp(settings.posDeviceIp ?? "");
|
||||
setPosDevicePort(String(settings.posDevicePort ?? 8088));
|
||||
}, [settings]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPatch<BranchPrintSettings>(
|
||||
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`,
|
||||
{
|
||||
receiptPrinterIp: receiptIp.trim() || null,
|
||||
receiptPrinterPort: parseInt(receiptPort, 10) || 9100,
|
||||
kitchenPrinterIp: kitchenIp.trim() || null,
|
||||
kitchenPrinterPort: parseInt(kitchenPort, 10) || 9100,
|
||||
paperWidthMm: paperWidth === "58" ? 58 : 80,
|
||||
autoCutEnabled: autoCut,
|
||||
receiptHeader: receiptHeader.trim() || null,
|
||||
receiptFooter: receiptFooter.trim() || null,
|
||||
wifiPassword: wifiPassword.trim() || null,
|
||||
posDeviceIp: posDeviceIp.trim() || null,
|
||||
posDevicePort: parseInt(posDevicePort, 10) || 8088,
|
||||
}
|
||||
),
|
||||
onSuccess: () => {
|
||||
setMessage(t("settingsSaved"));
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (branchesLoading) {
|
||||
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
|
||||
}
|
||||
|
||||
if (!branchId) {
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">{t("noBranchForPrinter")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-6 pb-4 pt-6">
|
||||
<CardTitle className="text-base font-medium">{t("printerSettings")}</CardTitle>
|
||||
{onOpenPrintTest ? (
|
||||
<Button variant="outline" size="sm" onClick={onOpenPrintTest}>
|
||||
{tSettings("nav.printTest")}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-6 pb-6 pt-0">
|
||||
{message ? (
|
||||
<p className="rounded-lg border border-border/80 bg-muted/40 px-4 py-2.5 text-xs">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">
|
||||
<Input
|
||||
id="receipt-ip"
|
||||
value={receiptIp}
|
||||
onChange={(e) => setReceiptIp(e.target.value)}
|
||||
placeholder="192.168.1.100"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="receipt-port">
|
||||
<Input
|
||||
id="receipt-port"
|
||||
value={receiptPort}
|
||||
onChange={(e) => setReceiptPort(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("kitchenPrinter")} htmlFor="kitchen-ip">
|
||||
<Input
|
||||
id="kitchen-ip"
|
||||
value={kitchenIp}
|
||||
onChange={(e) => setKitchenIp(e.target.value)}
|
||||
placeholder="192.168.1.101"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="kitchen-port">
|
||||
<Input
|
||||
id="kitchen-port"
|
||||
value={kitchenPort}
|
||||
onChange={(e) => setKitchenPort(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("paperWidth")} htmlFor="paper-width">
|
||||
<select
|
||||
id="paper-width"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
value={paperWidth}
|
||||
onChange={(e) => setPaperWidth(e.target.value)}
|
||||
>
|
||||
<option value="80">80mm</option>
|
||||
<option value="58">58mm</option>
|
||||
</select>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("autoCut")} htmlFor="auto-cut">
|
||||
<label className="flex h-10 items-center gap-2 text-sm">
|
||||
<input
|
||||
id="auto-cut"
|
||||
type="checkbox"
|
||||
checked={autoCut}
|
||||
onChange={(e) => setAutoCut(e.target.checked)}
|
||||
/>
|
||||
{t("autoCut")}
|
||||
</label>
|
||||
</LabeledField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t border-border/80 pt-6">
|
||||
<LabeledField label={t("receiptHeader")} htmlFor="receipt-header">
|
||||
<Input
|
||||
id="receipt-header"
|
||||
value={receiptHeader}
|
||||
onChange={(e) => setReceiptHeader(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("receiptFooter")} htmlFor="receipt-footer">
|
||||
<Input
|
||||
id="receipt-footer"
|
||||
value={receiptFooter}
|
||||
onChange={(e) => setReceiptFooter(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("wifiOnReceipt")} htmlFor="wifi-pass">
|
||||
<Input
|
||||
id="wifi-pass"
|
||||
value={wifiPassword}
|
||||
onChange={(e) => setWifiPassword(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border/80 bg-muted/20 p-4 sm:p-5">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("posDeviceSection")}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{t("posDeviceHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledField label={t("posDeviceIp")} htmlFor="pos-device-ip">
|
||||
<Input
|
||||
id="pos-device-ip"
|
||||
value={posDeviceIp}
|
||||
onChange={(e) => setPosDeviceIp(e.target.value)}
|
||||
placeholder="192.168.1.50"
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("port")} htmlFor="pos-device-port">
|
||||
<Input
|
||||
id="pos-device-port"
|
||||
value={posDevicePort}
|
||||
onChange={(e) => setPosDevicePort(e.target.value)}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-border/80 pt-4">
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user