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:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Plus, Receipt, Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useConfirm } from "@/components/providers/confirm-provider";
interface TaxRow {
id: string;
name: string;
rate: number;
isDefault: boolean;
isRequired: boolean;
isCompound: boolean;
}
export function TaxesScreen() {
const t = useTranslations("taxes");
const tCommon = useTranslations("common");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const canEdit = isCafeOwner(role);
const queryClient = useQueryClient();
const confirmDialog = useConfirm();
const [name, setName] = useState("");
const [rate, setRate] = useState("9");
const { data: taxes = [], isLoading } = useQuery({
queryKey: ["taxes", cafeId],
queryFn: () => apiGet<TaxRow[]>(`/api/cafes/${cafeId}/taxes`),
enabled: !!cafeId,
});
const addTax = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/taxes`, {
name,
rate: parseFloat(rate),
isDefault: taxes.length === 0,
isRequired: true,
isCompound: false,
}),
onSuccess: () => {
setName("");
queryClient.invalidateQueries({ queryKey: ["taxes", cafeId] });
},
});
const setDefault = useMutation({
mutationFn: (id: string) => apiPatch(`/api/cafes/${cafeId}/taxes/${id}`, { isDefault: true }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["taxes", cafeId] }),
});
const removeTax = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/taxes/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["taxes", cafeId] });
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
},
});
const handleRemove = async (tax: TaxRow) => {
const ok = await confirmDialog({
description: t("deleteConfirm", { name: tax.name }),
variant: "destructive",
confirmLabel: tCommon("delete"),
});
if (!ok) return;
removeTax.mutate(tax.id);
};
if (!cafeId) return null;
return (
<div className="space-y-6">
<PageHeader
title={t("title")}
subtitle={t("subtitle")}
action={
canEdit ? (
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!name.trim() || !rate}
onClick={() => addTax.mutate()}
>
<Plus className="me-2 h-4 w-4" />
{t("addTax")}
</Button>
) : undefined
}
/>
{!canEdit ? (
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
) : (
<Card className="rounded-xl border border-border/80 bg-card shadow-sm">
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
<LabeledField label={t("name")} htmlFor="tax-name">
<Input id="tax-name" value={name} onChange={(e) => setName(e.target.value)} />
</LabeledField>
<LabeledField label={t("rate")} htmlFor="tax-rate">
<Input
id="tax-rate"
value={rate}
onChange={(e) => setRate(e.target.value)}
inputMode="decimal"
dir="ltr"
className="text-end"
/>
</LabeledField>
<p className="text-xs text-muted-foreground sm:col-span-3">{t("hint")}</p>
</CardContent>
</Card>
)}
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : taxes.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="space-y-2">
{taxes.map((tax) => (
<li
key={tax.id}
className="flex items-center gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm transition-colors hover:border-[#0F6E56]/40"
>
<Receipt className="h-5 w-5 shrink-0 text-[#0F6E56]" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{tax.name}</p>
<p className="text-xs text-muted-foreground">
{formatNumber(tax.rate)}٪ {tax.isRequired ? t("required") : t("optional")}
</p>
</div>
<span className="text-sm font-medium text-[#0F6E56]">{formatNumber(tax.rate)}٪</span>
<div className="flex shrink-0 items-center gap-2">
{tax.isDefault ? (
<Badge className="border-[#0F6E56]/30 bg-[#E1F5EE] text-[#0F6E56]">{t("default")}</Badge>
) : canEdit ? (
<Button size="sm" variant="outline" onClick={() => setDefault.mutate(tax.id)}>
{t("setDefault")}
</Button>
) : null}
{canEdit ? (
<Button
size="sm"
variant="ghost"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={removeTax.isPending}
onClick={() => handleRemove(tax)}
aria-label={t("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
</div>
);
}