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,420 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Building2, RotateCcw, Trash2, Eye } from "lucide-react";
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
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";
import { PageHeader } from "@/components/layout/page-header";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
type Branch = {
id: string;
name: string;
address?: string;
city?: string;
phone?: string;
loginPhone?: string;
managerName?: string;
isPendingDeletion?: boolean;
deletedAt?: string | null;
scheduledPermanentDeleteAt?: string | null;
daysUntilPermanentDelete?: number | null;
};
function purgeCountdownLabel(
branch: Branch,
t: (key: string, values?: Record<string, number | string>) => string
): string {
const days = branch.daysUntilPermanentDelete ?? 0;
if (days <= 0) return t("purgeToday");
if (days === 1) return t("purgeInOneDay");
return t("purgeInDays", { days });
}
export function BranchesScreen() {
const t = useTranslations("branchesPage");
const tCommon = useTranslations("common");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const setBranchId = useBranchStore((s) => s.setBranchId);
const queryClient = useQueryClient();
const [newBranchName, setNewBranchName] = useState("");
const [loginPhone, setLoginPhone] = useState("");
const [managerName, setManagerName] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
const [reviewBranch, setReviewBranch] = useState<Branch | null>(null);
const { data: branches = [], isLoading } = useQuery({
queryKey: ["branches", cafeId, "manage"],
queryFn: () =>
apiGet<Branch[]>(
`/api/cafes/${cafeId}/branches?includePendingDeletion=true`
),
enabled: !!cafeId && isCafeOwner(role),
});
const activeBranches = useMemo(
() => branches.filter((b) => !b.isPendingDeletion),
[branches]
);
const pendingBranches = useMemo(
() => branches.filter((b) => b.isPendingDeletion),
[branches]
);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["branches", cafeId] });
};
const createBranch = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/branches`, {
name: newBranchName.trim(),
loginPhone: loginPhone.trim(),
managerName: managerName.trim() || undefined,
}),
onSuccess: () => {
setNewBranchName("");
setLoginPhone("");
setManagerName("");
setError(null);
setMessage(t("created"));
invalidate();
},
onError: () => setError(t("createError")),
});
const deleteBranch = useMutation({
mutationFn: (id: string) =>
apiDelete(`/api/cafes/${cafeId}/branches/${id}`),
onSuccess: (_, id) => {
setDeleteTarget(null);
setMessage(t("deleteScheduled"));
setError(null);
const current = useBranchStore.getState().branchId;
if (current === id) setBranchId(null);
invalidate();
},
onError: () => {
setDeleteTarget(null);
setError(t("deleteError"));
},
});
const restoreBranch = useMutation({
mutationFn: (id: string) =>
apiPost<Branch>(`/api/cafes/${cafeId}/branches/${id}/restore`, {}),
onSuccess: () => {
setMessage(t("restored"));
setError(null);
invalidate();
},
onError: () => setError(t("restoreError")),
});
if (!cafeId) return null;
if (!isCafeOwner(role)) {
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
</div>
);
}
const canSubmit =
newBranchName.trim().length > 0 &&
loginPhone.trim().length >= 10 &&
!createBranch.isPending;
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
{message ? (
<p className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE] px-4 py-3 text-sm text-[#0F6E56]">
{message}
</p>
) : null}
{error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</p>
) : null}
{pendingBranches.length > 0 ? (
<Card className="rounded-xl border border-border/60 bg-muted/30">
<CardHeader>
<CardTitle className="text-base text-muted-foreground">
{t("pendingTitle")}
</CardTitle>
<p className="text-xs text-muted-foreground">{t("pendingHint")}</p>
</CardHeader>
<CardContent>
<ul className="divide-y divide-border rounded-lg border border-dashed border-border">
{pendingBranches.map((b) => (
<li
key={b.id}
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-3">
<Building2
className="mt-0.5 h-8 w-8 shrink-0 text-muted-foreground/50"
aria-hidden
/>
<div>
<p className="font-medium text-muted-foreground">{b.name}</p>
<p className="mt-1 text-xs font-medium text-amber-800">
{purgeCountdownLabel(b, t)}
</p>
{b.scheduledPermanentDeleteAt ? (
<p className="text-[10px] text-muted-foreground" dir="ltr">
{new Date(b.scheduledPermanentDeleteAt).toLocaleString()}
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setReviewBranch(b)}
>
<Eye className="h-3.5 w-3.5 me-1.5" />
{t("review")}
</Button>
<Button
type="button"
size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={restoreBranch.isPending}
onClick={() => restoreBranch.mutate(b.id)}
>
<RotateCcw className="h-3.5 w-3.5 me-1.5" />
{t("restore")}
</Button>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader>
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : activeBranches.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{activeBranches.map((b) => (
<li
key={b.id}
className="flex flex-col gap-2 px-4 py-3 text-start sm:flex-row sm:items-center sm:justify-between"
>
<div>
<span className="font-medium">{b.name}</span>
{b.managerName ? (
<p className="text-xs text-muted-foreground">{b.managerName}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="text-xs text-muted-foreground">
{b.loginPhone ? (
<span dir="ltr" className="font-medium text-foreground">
{t("loginPhone")}: {b.loginPhone}
</span>
) : null}
{(b.city || b.address) && (
<p>{[b.city, b.address].filter(Boolean).join(" · ")}</p>
)}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setReviewBranch(b)}
>
<Eye className="h-3.5 w-3.5 me-1.5" />
{t("review")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className={cn(
"border-destructive/40 text-destructive hover:bg-destructive/10"
)}
disabled={activeBranches.length <= 1}
onClick={() => setDeleteTarget(b)}
>
<Trash2 className="h-3.5 w-3.5 me-1.5" />
{t("delete")}
</Button>
</div>
</li>
))}
</ul>
)}
<form
className="space-y-3 border-t border-border pt-4"
onSubmit={(e) => {
e.preventDefault();
if (canSubmit) createBranch.mutate();
}}
>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{t("addSection")}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<LabeledField label={t("newName")} htmlFor="new-branch-name">
<Input
id="new-branch-name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("loginPhone")} htmlFor="branch-login-phone">
<Input
id="branch-login-phone"
value={loginPhone}
onChange={(e) => setLoginPhone(e.target.value)}
placeholder="09121234567"
dir="ltr"
className="text-end"
autoComplete="tel"
/>
</LabeledField>
<LabeledField
label={t("managerName")}
htmlFor="branch-manager-name"
className="sm:col-span-2"
>
<Input
id="branch-manager-name"
value={managerName}
onChange={(e) => setManagerName(e.target.value)}
placeholder={t("managerNamePlaceholder")}
/>
</LabeledField>
</div>
<Button
type="submit"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!canSubmit}
>
{createBranch.isPending ? "..." : t("add")}
</Button>
<p className="text-xs text-muted-foreground">{t("masterPlanHint")}</p>
</form>
<p className="text-xs text-muted-foreground">{t("branchSelectHint")}</p>
</CardContent>
</Card>
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteWarning")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteBranch.mutate(deleteTarget.id)}
>
{t("deleteConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!reviewBranch}
onOpenChange={(open) => !open && setReviewBranch(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("reviewTitle")}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
{reviewBranch ? (
<dl className="mt-2 space-y-2 text-start text-sm text-foreground">
<div>
<dt className="text-xs text-muted-foreground">{t("newName")}</dt>
<dd className="font-medium">{reviewBranch.name}</dd>
</div>
{reviewBranch.managerName ? (
<div>
<dt className="text-xs text-muted-foreground">{t("managerName")}</dt>
<dd>{reviewBranch.managerName}</dd>
</div>
) : null}
{reviewBranch.loginPhone ? (
<div>
<dt className="text-xs text-muted-foreground">{t("loginPhone")}</dt>
<dd dir="ltr">{reviewBranch.loginPhone}</dd>
</div>
) : null}
{(reviewBranch.city || reviewBranch.address) && (
<div>
<dt className="text-xs text-muted-foreground">{t("location")}</dt>
<dd>
{[reviewBranch.city, reviewBranch.address]
.filter(Boolean)
.join(" · ")}
</dd>
</div>
)}
{reviewBranch.isPendingDeletion ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{purgeCountdownLabel(reviewBranch, t)}
</p>
) : null}
</dl>
) : null}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setReviewBranch(null)}>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client";
import type { Coupon, CouponType } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export function CouponsScreen() {
const t = useTranslations("coupons");
const tCommon = useTranslations("common");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [code, setCode] = useState("");
const [type, setType] = useState<CouponType>("Percentage");
const [value, setValue] = useState("10");
const { data: coupons = [], isLoading } = useQuery({
queryKey: ["coupons", cafeId],
queryFn: () => apiGet<Coupon[]>(`/api/cafes/${cafeId}/coupons`),
enabled: !!cafeId,
});
const createCoupon = useMutation({
mutationFn: () =>
apiPost<Coupon>(`/api/cafes/${cafeId}/coupons`, {
code,
type,
value: Number(value),
isActive: true,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
setShowForm(false);
setCode("");
setValue("10");
},
});
if (!cafeId) return null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{t("title")}</h2>
<Button onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4" />
{t("addCoupon")}
</Button>
</div>
{showForm && (
<Card>
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
<LabeledField label={t("code")} htmlFor="coupon-code">
<Input
id="coupon-code"
value={code}
onChange={(e) => setCode(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("type")} htmlFor="coupon-type">
<select
id="coupon-type"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={type}
onChange={(e) => setType(e.target.value as CouponType)}
>
<option value="Percentage">{t("types.Percentage")}</option>
<option value="FixedAmount">{t("types.FixedAmount")}</option>
</select>
</LabeledField>
<LabeledField label={t("value")} htmlFor="coupon-value">
<Input
id="coupon-value"
value={value}
onChange={(e) => setValue(e.target.value)}
type="number"
dir="ltr"
className="text-end"
/>
</LabeledField>
<div className="flex gap-2 sm:col-span-3">
<Button onClick={() => createCoupon.mutate()} disabled={createCoupon.isPending}>
{tCommon("save")}
</Button>
<Button variant="outline" onClick={() => setShowForm(false)}>
{tCommon("cancel")}
</Button>
</div>
</CardContent>
</Card>
)}
{isLoading ? (
<p className="text-muted-foreground">{tCommon("loading")}</p>
) : coupons.length === 0 ? (
<p className="text-muted-foreground">{t("noCoupons")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{coupons.map((c) => (
<Card key={c.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="font-mono text-lg">{c.code}</CardTitle>
<Badge variant={c.isActive ? "default" : "secondary"}>
{c.isActive ? t("active") : t("inactive")}
</Badge>
</div>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<p>
{t("type")}: {t(`types.${c.type}`)}
</p>
<p>
{t("value")}: {formatNumber(c.value)}
{c.type === "Percentage" ? "%" : " ت"}
</p>
<p>
{t("usage")}: {formatNumber(c.usedCount)}
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Plus, Pencil, Search } from "lucide-react";
import { apiGet } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
export function CrmScreen() {
const t = useTranslations("crm");
const tCommon = useTranslations("common");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const { data: customers = [], isLoading } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch],
queryFn: () =>
apiGet<Customer[]>(
`/api/cafes/${cafeId}/customers${debouncedSearch ? `?q=${encodeURIComponent(debouncedSearch)}` : ""}`
),
enabled: !!cafeId,
});
const openWizard = (mode: CustomerWizardMode, customer?: Customer) => {
setWizardMode(mode);
setEditingCustomer(customer ?? null);
setWizardOpen(true);
};
const refreshCustomers = () => {
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
};
if (!cafeId) return null;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-bold">{t("title")}</h2>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
onClick={() => openWizard("create")}
>
<Plus className="me-2 h-4 w-4" />
{t("addCustomer")}
</Button>
</div>
<div className="flex flex-wrap items-end gap-2">
<LabeledField label={tCommon("search")} htmlFor="crm-search" className="min-w-[12rem] flex-1">
<Input
id="crm-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && setDebouncedSearch(search)}
/>
</LabeledField>
<Button variant="outline" onClick={() => setDebouncedSearch(search)}>
<Search className="h-4 w-4" />
{tCommon("search")}
</Button>
</div>
{isLoading ? (
<p className="text-muted-foreground">{tCommon("loading")}</p>
) : customers.length === 0 ? (
<p className="text-muted-foreground">{t("noCustomers")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customers.map((c) => (
<Card key={c.id} className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base">{c.name}</CardTitle>
<Badge variant="secondary">{t(`groups.${c.group}`)}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1 text-sm text-muted-foreground">
<p dir="ltr" className="text-end font-mono">
{c.phone}
</p>
{c.nationalId ? (
<p>
{t("nationalId")}: {c.nationalId}
</p>
) : null}
<p>
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => openWizard("edit", c)}
>
<Pencil className="me-1 h-3.5 w-3.5" />
{tCommon("edit")}
</Button>
</CardContent>
</Card>
))}
</div>
)}
<CustomerWizard
open={wizardOpen}
mode={wizardMode}
cafeId={cafeId}
customer={editingCustomer}
onClose={() => setWizardOpen(false)}
onSaved={refreshCustomers}
/>
</div>
);
}
@@ -0,0 +1,363 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
import type { Customer, CustomerGroup } from "@/lib/api/types";
import { useIsRtl } from "@/lib/use-is-rtl";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const GROUPS: CustomerGroup[] = ["Regular", "Vip", "New", "Employee"];
const STEP_COUNT = 4;
export type CustomerWizardMode = "create" | "edit";
type CustomerWizardProps = {
open: boolean;
mode: CustomerWizardMode;
cafeId: string;
customer?: Customer | null;
onClose: () => void;
onSaved: () => void;
};
type FormState = {
name: string;
phone: string;
nationalId: string;
birthDateJalali: string;
group: CustomerGroup;
loyaltyPoints: string;
referredBy: string;
};
function emptyForm(): FormState {
return {
name: "",
phone: "",
nationalId: "",
birthDateJalali: "",
group: "Regular",
loyaltyPoints: "0",
referredBy: "",
};
}
function fromCustomer(c: Customer): FormState {
return {
name: c.name,
phone: c.phone,
nationalId: c.nationalId ?? "",
birthDateJalali: c.birthDateJalali ?? "",
group: c.group,
loyaltyPoints: String(c.loyaltyPoints),
referredBy: c.referredBy ?? "",
};
}
export function CustomerWizard({
open,
mode,
cafeId,
customer,
onClose,
onSaved,
}: CustomerWizardProps) {
const t = useTranslations("crm.wizard");
const tCrm = useTranslations("crm");
const tCommon = useTranslations("common");
const isRtl = useIsRtl();
const numberLocale = isRtl ? "fa-IR" : "en-US";
const [step, setStep] = useState(1);
const [form, setForm] = useState<FormState>(emptyForm);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setStep(1);
setError(null);
setForm(mode === "edit" && customer ? fromCustomer(customer) : emptyForm());
}, [open, mode, customer]);
const stepLabels = useMemo(
() => [t("steps.contact"), t("steps.profile"), t("steps.loyalty"), t("steps.confirm")],
[t]
);
const canNext = () => {
if (step === 1) return form.name.trim().length > 0 && form.phone.trim().length >= 10;
return true;
};
const save = useMutation({
mutationFn: async () => {
const loyalty = parseInt(form.loyaltyPoints, 10) || 0;
if (mode === "create") {
return apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
name: form.name.trim(),
phone: form.phone.trim(),
nationalId: form.nationalId.trim() || undefined,
birthDateJalali: form.birthDateJalali.trim() || undefined,
group: form.group,
referredBy: form.referredBy.trim() || undefined,
});
}
if (!customer?.id) throw new Error("missing customer");
return apiPatch<Customer>(`/api/cafes/${cafeId}/customers/${customer.id}`, {
name: form.name.trim(),
phone: form.phone.trim(),
nationalId: form.nationalId.trim() || undefined,
birthDateJalali: form.birthDateJalali.trim() || undefined,
group: form.group,
loyaltyPoints: loyalty,
referredBy: form.referredBy.trim() || undefined,
});
},
onSuccess: () => {
onSaved();
onClose();
},
onError: (err: Error) => {
const code = err instanceof ApiClientError ? err.code : "";
if (code === "DUPLICATE_PHONE") setError(t("errors.duplicatePhone"));
else setError(t("errors.generic"));
},
});
if (!open) return null;
const BackIcon = isRtl ? ChevronRight : ChevronLeft;
const NextIcon = isRtl ? ChevronLeft : ChevronRight;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="customer-wizard-title"
>
<Card className="flex max-h-[min(90dvh,640px)] w-full max-w-lg flex-col overflow-hidden shadow-xl">
<CardHeader className="shrink-0 space-y-3 border-b border-border/80 pb-4">
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle id="customer-wizard-title" className="text-lg">
{mode === "create" ? t("titleCreate") : t("titleEdit")}
</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{t("stepOf", { current: step, total: STEP_COUNT })}
</p>
</div>
<Button type="button" size="icon" variant="ghost" onClick={onClose} aria-label={tCommon("cancel")}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-1">
{stepLabels.map((label, i) => {
const n = i + 1;
return (
<div
key={label}
className={cn(
"h-1 flex-1 rounded-full transition-colors",
n <= step ? "bg-[#0F6E56]" : "bg-muted"
)}
title={label}
/>
);
})}
</div>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{stepLabels[step - 1]}
</p>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-y-auto py-4">
{error ? (
<p className="mb-3 rounded-md border border-[#A32D2D]/30 bg-red-50 px-3 py-2 text-sm text-[#A32D2D]">
{error}
</p>
) : null}
{step === 1 && (
<div className="space-y-3">
<LabeledField label={tCrm("name")} htmlFor="wiz-name">
<Input
id="wiz-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
autoFocus
/>
</LabeledField>
<LabeledField label={tCrm("phone")} htmlFor="wiz-phone">
<Input
id="wiz-phone"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
dir="ltr"
className="text-end"
inputMode="tel"
/>
</LabeledField>
</div>
)}
{step === 2 && (
<div className="space-y-3">
<LabeledField label={tCrm("nationalId")} htmlFor="wiz-national-id">
<Input
id="wiz-national-id"
value={form.nationalId}
onChange={(e) => setForm((f) => ({ ...f, nationalId: e.target.value }))}
dir="ltr"
className="text-end"
maxLength={10}
inputMode="numeric"
/>
</LabeledField>
<LabeledField label={tCrm("birthDate")} htmlFor="wiz-birth" hint={t("birthHint")}>
<Input
id="wiz-birth"
value={form.birthDateJalali}
onChange={(e) => setForm((f) => ({ ...f, birthDateJalali: e.target.value }))}
dir="ltr"
className="text-end"
placeholder="1400/01/01"
/>
</LabeledField>
</div>
)}
{step === 3 && (
<div className="space-y-3">
<LabeledField label={tCrm("group")} htmlFor="wiz-group">
<select
id="wiz-group"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={form.group}
onChange={(e) =>
setForm((f) => ({ ...f, group: e.target.value as CustomerGroup }))
}
>
{GROUPS.map((g) => (
<option key={g} value={g}>
{tCrm(`groups.${g}`)}
</option>
))}
</select>
</LabeledField>
<LabeledField label={tCrm("loyaltyPoints")} htmlFor="wiz-points">
<Input
id="wiz-points"
value={form.loyaltyPoints}
onChange={(e) => setForm((f) => ({ ...f, loyaltyPoints: e.target.value }))}
inputMode="numeric"
dir="ltr"
className="text-end"
disabled={mode === "create"}
/>
</LabeledField>
{mode === "create" ? (
<p className="text-xs text-muted-foreground">{t("loyaltyCreateHint")}</p>
) : null}
<LabeledField label={t("referredBy")} htmlFor="wiz-referred">
<Input
id="wiz-referred"
value={form.referredBy}
onChange={(e) => setForm((f) => ({ ...f, referredBy: e.target.value }))}
dir="ltr"
className="text-end"
/>
</LabeledField>
</div>
)}
{step === 4 && (
<dl className="space-y-3 text-sm">
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
<dt className="text-muted-foreground">{tCrm("name")}</dt>
<dd className="font-medium">{form.name}</dd>
</div>
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
<dt className="text-muted-foreground">{tCrm("phone")}</dt>
<dd className="font-mono" dir="ltr">
{form.phone}
</dd>
</div>
{form.nationalId ? (
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
<dt className="text-muted-foreground">{tCrm("nationalId")}</dt>
<dd dir="ltr">{form.nationalId}</dd>
</div>
) : null}
{form.birthDateJalali ? (
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
<dt className="text-muted-foreground">{tCrm("birthDate")}</dt>
<dd dir="ltr">{form.birthDateJalali}</dd>
</div>
) : null}
<div className="flex justify-between gap-2 border-b border-border/60 pb-2">
<dt className="text-muted-foreground">{tCrm("group")}</dt>
<dd>
<Badge variant="secondary">{tCrm(`groups.${form.group}`)}</Badge>
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{tCrm("loyaltyPoints")}</dt>
<dd>{formatNumber(parseInt(form.loyaltyPoints, 10) || 0, numberLocale)}</dd>
</div>
</dl>
)}
</CardContent>
<div className="flex shrink-0 gap-2 border-t border-border/80 p-4">
{step > 1 ? (
<Button type="button" variant="outline" onClick={() => setStep((s) => s - 1)}>
<BackIcon className="me-1 h-4 w-4" />
{t("back")}
</Button>
) : (
<Button type="button" variant="outline" onClick={onClose}>
{tCommon("cancel")}
</Button>
)}
<div className="flex-1" />
{step < STEP_COUNT ? (
<Button
type="button"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!canNext()}
onClick={() => {
setError(null);
setStep((s) => s + 1);
}}
>
{t("next")}
<NextIcon className="ms-1 h-4 w-4" />
</Button>
) : (
<Button
type="button"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={save.isPending}
onClick={() => {
setError(null);
save.mutate();
}}
>
{save.isPending ? tCommon("loading") : tCommon("save")}
</Button>
)}
</div>
</Card>
</div>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useTranslations } from "next-intl";
import {
DISCOVER_TAXONOMY,
type CafeDiscoverProfile,
type DiscoverListField,
type DiscoverSingleField,
toggleListValue,
} from "@/lib/cafe-discover-profile";
import { cn } from "@/lib/utils";
type CafeDiscoverProfileEditorProps = {
value: CafeDiscoverProfile;
onChange: (next: CafeDiscoverProfile) => void;
disabled?: boolean;
};
export function CafeDiscoverProfileEditor({
value,
onChange,
disabled,
}: CafeDiscoverProfileEditorProps) {
const t = useTranslations("discoverProfile");
const setList = (field: DiscoverListField, id: string) => {
onChange({ ...value, [field]: toggleListValue(value[field], id) });
};
const setSingle = (field: DiscoverSingleField, id: string) => {
onChange({ ...value, [field]: value[field] === id ? null : id });
};
return (
<div className="space-y-5">
<ProfileSection label={t("sections.themes")} hint={t("hints.themes")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.themes}
selected={value.themes}
label={(id) => t(`themes.${id}`)}
onToggle={(id) => setList("themes", id)}
disabled={disabled}
/>
</ProfileSection>
<ProfileSection label={t("sections.occasions")} hint={t("hints.occasions")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.occasions}
selected={value.occasions}
label={(id) => t(`occasions.${id}`)}
onToggle={(id) => setList("occasions", id)}
disabled={disabled}
/>
</ProfileSection>
<ProfileSection label={t("sections.spaceFeatures")} hint={t("hints.spaceFeatures")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.spaceFeatures}
selected={value.spaceFeatures}
label={(id) => t(`spaceFeatures.${id}`)}
onToggle={(id) => setList("spaceFeatures", id)}
disabled={disabled}
/>
</ProfileSection>
<ProfileSection label={t("sections.vibes")} hint={t("hints.vibes")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.vibes}
selected={value.vibes}
label={(id) => t(`vibes.${id}`)}
onToggle={(id) => setList("vibes", id)}
disabled={disabled}
/>
</ProfileSection>
<div className="grid gap-4 sm:grid-cols-2">
<ProfileSection label={t("sections.size")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.sizes}
selected={value.size ? [value.size] : []}
label={(id) => t(`sizes.${id}`)}
onToggle={(id) => setSingle("size", id)}
disabled={disabled}
single
/>
</ProfileSection>
<ProfileSection label={t("sections.floors")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.floors}
selected={value.floors ? [value.floors] : []}
label={(id) => t(`floors.${id}`)}
onToggle={(id) => setSingle("floors", id)}
disabled={disabled}
single
/>
</ProfileSection>
<ProfileSection label={t("sections.noiseLevel")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.noiseLevels}
selected={value.noiseLevel ? [value.noiseLevel] : []}
label={(id) => t(`noiseLevels.${id}`)}
onToggle={(id) => setSingle("noiseLevel", id)}
disabled={disabled}
single
/>
</ProfileSection>
<ProfileSection label={t("sections.priceTier")}>
<ChipGrid
ids={DISCOVER_TAXONOMY.priceTiers}
selected={value.priceTier ? [value.priceTier] : []}
label={(id) => t(`priceTiers.${id}`)}
onToggle={(id) => setSingle("priceTier", id)}
disabled={disabled}
single
/>
</ProfileSection>
</div>
</div>
);
}
function ProfileSection({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{label}
</p>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
{children}
</div>
);
}
function ChipGrid({
ids,
selected,
label,
onToggle,
disabled,
single,
}: {
ids: readonly string[];
selected: string[];
label: (id: string) => string;
onToggle: (id: string) => void;
disabled?: boolean;
single?: boolean;
}) {
return (
<div className="flex flex-wrap gap-2">
{ids.map((id) => {
const active = selected.includes(id);
return (
<button
key={id}
type="button"
disabled={disabled}
onClick={() => onToggle(id)}
className={cn(
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition active:scale-[0.98]",
active
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 bg-card text-foreground hover:border-[#0F6E56]/40",
disabled && "pointer-events-none opacity-50"
)}
aria-pressed={active}
>
{label(id)}
{!single && active ? (
<span className="ms-1 opacity-70" aria-hidden>
</span>
) : null}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPut } from "@/lib/api/client";
import { adminGet, adminPut } from "@/lib/api/admin-client";
import {
EMPTY_DISCOVER_PROFILE,
type CafeDiscoverProfile,
} from "@/lib/cafe-discover-profile";
import { CafeDiscoverProfileEditor } from "@/components/discover/cafe-discover-profile-editor";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { notify } from "@/lib/notify";
type ApiDiscoverProfile = {
themes: string[];
size?: string | null;
floors?: string | null;
vibes: string[];
occasions: string[];
spaceFeatures: string[];
noiseLevel?: string | null;
priceTier?: string | null;
};
function fromApi(d: ApiDiscoverProfile): CafeDiscoverProfile {
return {
themes: d.themes ?? [],
size: d.size ?? null,
floors: d.floors ?? null,
vibes: d.vibes ?? [],
occasions: d.occasions ?? [],
spaceFeatures: d.spaceFeatures ?? [],
noiseLevel: d.noiseLevel ?? null,
priceTier: d.priceTier ?? null,
};
}
function toApiBody(p: CafeDiscoverProfile) {
return {
themes: p.themes,
size: p.size,
floors: p.floors,
vibes: p.vibes,
occasions: p.occasions,
spaceFeatures: p.spaceFeatures,
noiseLevel: p.noiseLevel,
priceTier: p.priceTier,
};
}
type CafeDiscoverProfilePanelProps = {
cafeId: string;
mode: "merchant" | "admin";
compact?: boolean;
};
export function CafeDiscoverProfilePanel({
cafeId,
mode,
compact,
}: CafeDiscoverProfilePanelProps) {
const t = useTranslations(
mode === "admin" ? "admin.cafes.discoverProfile" : "settings.discoverProfile"
);
const qc = useQueryClient();
const [profile, setProfile] = useState<CafeDiscoverProfile>(EMPTY_DISCOVER_PROFILE);
const queryKey =
mode === "admin"
? ["admin", "cafe-discover-profile", cafeId]
: ["cafe-discover-profile", cafeId];
const { data, isLoading } = useQuery({
queryKey,
queryFn: async () => {
if (mode === "admin") {
const res = await adminGet<ApiDiscoverProfile & { cafeId: string; cafeName: string }>(
`/api/admin/cafes/${cafeId}/discover-profile`
);
return fromApi(res);
}
const res = await apiGet<ApiDiscoverProfile>(`/api/cafes/${cafeId}/discover-profile`);
return fromApi(res);
},
enabled: !!cafeId,
});
useEffect(() => {
if (data) setProfile(data);
}, [data]);
const save = useMutation({
mutationFn: () => {
const body = toApiBody(profile);
return mode === "admin"
? adminPut(`/api/admin/cafes/${cafeId}/discover-profile`, body)
: apiPut(`/api/cafes/${cafeId}/discover-profile`, body);
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey });
notify.success(t("saved"));
},
});
const content = (
<>
{!compact ? (
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
) : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : (
<CafeDiscoverProfileEditor
value={profile}
onChange={setProfile}
disabled={save.isPending}
/>
)}
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={save.isPending || isLoading}
onClick={() => save.mutate()}
>
{t("save")}
</Button>
</>
);
if (compact) {
return <div className="space-y-4">{content}</div>;
}
return (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{content}</CardContent>
</Card>
);
}
@@ -0,0 +1,347 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchCafePublicProfile,
removeGalleryPhoto,
updateCafePublicProfile,
uploadGalleryPhoto,
type CafeProfileEdit,
} from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type Props = { cafeId: string };
type Tab = "info" | "gallery" | "hours" | "social";
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
export function CafePublicProfilePanel({ cafeId }: Props) {
const t = useTranslations("cafePublicProfile");
const qc = useQueryClient();
const [tab, setTab] = useState<Tab>("info");
const [saved, setSaved] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Server state ──────────────────────────────────────────────────────────
const { data: profile, isLoading } = useQuery({
queryKey: ["cafe-public-profile", cafeId],
queryFn: () => fetchCafePublicProfile(cafeId),
});
// ── Local edit state ──────────────────────────────────────────────────────
const [description, setDescription] = useState<string>("");
const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data
if (profile && !initialized) {
setDescription(profile.description ?? "");
setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours());
setInitialized(true);
}
// ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({
mutationFn: () =>
updateCafePublicProfile(cafeId, {
description,
instagramHandle: instagram || null,
websiteUrl: website || null,
workingHours: hours,
}),
onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
// ── Gallery upload ────────────────────────────────────────────────────────
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError(null);
try {
const gallery = await uploadGalleryPhoto(cafeId, file);
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
old ? { ...old, galleryUrls: gallery } : old
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : t("uploadFailed");
setUploadError(msg.includes("GALLERY_FULL") ? t("galleryFull") : t("uploadFailed"));
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const removeMutation = useMutation({
mutationFn: (url: string) => removeGalleryPhoto(cafeId, url),
onSuccess: (gallery) => {
qc.setQueryData<CafeProfileEdit>(["cafe-public-profile", cafeId], (old) =>
old ? { ...old, galleryUrls: gallery } : old
);
},
});
// ── Hours helpers ─────────────────────────────────────────────────────────
const setDayField = (
day: keyof WorkingHours,
field: "isOpen" | "open" | "close",
value: string | boolean
) => {
setHours((prev) => ({
...prev,
[day]: {
...((prev[day] as object) ?? { isOpen: false, open: "", close: "" }),
[field]: value,
},
}));
};
if (isLoading) {
return <p className="text-sm text-muted-foreground p-4">{t("loading")}</p>;
}
const tabs: { id: Tab; label: string }[] = [
{ id: "info", label: t("tabs.info") },
{ id: "gallery", label: t("tabs.gallery") },
{ id: "hours", label: t("tabs.hours") },
{ id: "social", label: t("tabs.social") },
];
return (
<div className="space-y-4">
<div>
<h2 className="text-base font-semibold">{t("title")}</h2>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 rounded-xl border border-border/80 bg-muted/40 p-1">
{tabs.map((tb) => (
<button
key={tb.id}
type="button"
onClick={() => setTab(tb.id)}
className={cn(
"flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
tab === tb.id
? "bg-white shadow-sm text-[#0F6E56]"
: "text-muted-foreground hover:text-foreground"
)}
>
{tb.label}
</button>
))}
</div>
{/* ── Info tab ─────────────────────────────────────────────────────── */}
{tab === "info" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div className="space-y-1">
<Label>{t("description")}</Label>
<textarea
value={description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
rows={5}
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
/>
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
</CardContent>
</Card>
)}
{/* ── Gallery tab ──────────────────────────────────────────────────── */}
{tab === "gallery" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div>
<p className="text-sm font-medium">{t("gallery")}</p>
<p className="text-xs text-muted-foreground">{t("galleryHint")}</p>
</div>
{/* Existing photos */}
{profile?.galleryUrls && profile.galleryUrls.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{profile.galleryUrls.map((url) => {
const src = resolveMediaUrl(url);
return (
<div key={url} className="group relative">
<div
className="aspect-square rounded-lg bg-cover bg-center"
style={{ backgroundImage: src ? `url(${src})` : undefined }}
/>
<button
type="button"
onClick={() => removeMutation.mutate(url)}
disabled={removeMutation.isPending}
className="absolute end-1 top-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white opacity-0 transition group-hover:opacity-100 cursor-pointer"
>
{t("removePhoto")}
</button>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">هنوز عکسی آپلود نشده</p>
)}
{/* Upload button */}
<div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFileChange}
/>
<Button
variant="outline"
size="sm"
disabled={uploading || (profile?.galleryUrls?.length ?? 0) >= 8}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? t("uploading") : t("uploadPhoto")}
</Button>
{uploadError && (
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
)}
</div>
</CardContent>
</Card>
)}
{/* ── Working hours tab ─────────────────────────────────────────────── */}
{tab === "hours" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-3 p-4">
<p className="text-sm font-medium">{t("workingHours")}</p>
<div className="space-y-2">
{DAY_KEYS.map((day) => {
const d = hours[day] as { isOpen: boolean; open?: string; close?: string } | null;
return (
<div key={day} className="flex flex-wrap items-center gap-3 rounded-lg border border-border/60 px-3 py-2">
<span className="w-20 text-sm font-medium">{t(`days.${day}`)}</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={d?.isOpen ?? false}
onChange={(e) => setDayField(day, "isOpen", e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<span className="text-xs">{t("isOpen")}</span>
</label>
{d?.isOpen && (
<div className="flex items-center gap-2">
<input
type="time"
value={d.open ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "open", e.target.value)}
className="rounded border border-border/80 px-2 py-1 text-xs"
dir="ltr"
/>
<span className="text-xs text-muted-foreground"></span>
<input
type="time"
value={d.close ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDayField(day, "close", e.target.value)}
className="rounded border border-border/80 px-2 py-1 text-xs"
dir="ltr"
/>
</div>
)}
</div>
);
})}
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
</CardContent>
</Card>
)}
{/* ── Social tab ───────────────────────────────────────────────────── */}
{tab === "social" && (
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4">
<div className="space-y-1">
<Label>{t("instagram")}</Label>
<div className="flex items-center rounded-lg border border-border/80 px-3">
<span className="text-sm text-muted-foreground">@</span>
<Input
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder={t("instagramPlaceholder")}
className="border-0 ps-1 shadow-none"
dir="ltr"
/>
</div>
</div>
<div className="space-y-1">
<Label>{t("website")}</Label>
<Input
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder={t("websitePlaceholder")}
dir="ltr"
/>
</div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
</CardContent>
</Card>
)}
</div>
);
}
// ── Save button shared sub-component ─────────────────────────────────────────
function SaveButton({
saving,
saved,
onSave,
t,
}: {
saving: boolean;
saved: boolean;
onSave: () => void;
t: ReturnType<typeof useTranslations<"cafePublicProfile">>;
}) {
return (
<Button
onClick={onSave}
disabled={saving}
className="bg-[#0F6E56]"
>
{saving ? "…" : saved ? t("saved") : t("save")}
</Button>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function emptyHours(): WorkingHours {
const day = () => ({ isOpen: false, open: null, close: null });
return { sat: day(), sun: day(), mon: day(), tue: day(), wed: day(), thu: day(), fri: day() };
}
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Sparkles } from "lucide-react";
import { apiPostPublic, ApiClientError } from "@/lib/api/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type CoffeeAdvisorPick = {
name: string;
reason: string;
menuItemId?: string | null;
};
type CoffeeAdvisorResult = {
summary: string;
picks: CoffeeAdvisorPick[];
};
type CoffeeAdvisorPanelProps = {
cafeSlug: string;
};
export function CoffeeAdvisorPanel({ cafeSlug }: CoffeeAdvisorPanelProps) {
const t = useTranslations("discoverPublic.coffeeAdvisor");
const [purpose, setPurpose] = useState("");
const [result, setResult] = useState<CoffeeAdvisorResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submit = async () => {
const trimmed = purpose.trim();
if (trimmed.length < 3) return;
setLoading(true);
setError(null);
setResult(null);
try {
const data = await apiPostPublic<CoffeeAdvisorResult>(
"/api/public/coffee-advisor",
{ purpose: trimmed, cafeSlug }
);
setResult(data);
} catch (e) {
if (e instanceof ApiClientError && e.code === "AI_NOT_CONFIGURED") {
setError(t("notConfigured"));
} else {
setError(t("failed"));
}
} finally {
setLoading(false);
}
};
return (
<Card className="rounded-xl border border-primary/20 bg-gradient-to-b from-[#E1F5EE]/40 to-card">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" aria-hidden />
{t("title")}
</CardTitle>
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
placeholder={t("placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter" && !loading) void submit();
}}
/>
<Button
type="button"
className="shrink-0 bg-primary hover:bg-primary/90"
disabled={loading || purpose.trim().length < 3}
onClick={() => void submit()}
>
{loading ? t("loading") : t("submit")}
</Button>
</div>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
{result ? (
<div className="space-y-3 rounded-lg border border-primary/15 bg-card/80 p-3">
<p className="text-sm leading-relaxed">{result.summary}</p>
{result.picks.length > 0 ? (
<ul className="space-y-2">
{result.picks.map((pick) => (
<li
key={`${pick.name}-${pick.menuItemId ?? "x"}`}
className="rounded-lg border border-border/60 bg-background px-3 py-2"
>
<p className="text-sm font-medium text-primary">{pick.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{pick.reason}</p>
</li>
))}
</ul>
) : null}
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,337 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import {
fetchPublicCafe,
fetchPublicCafeReviews,
type WorkingHours,
} from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { formatNumber } from "@/lib/format";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CoffeeAdvisorPanel } from "@/components/discover/coffee-advisor-panel";
import { cn } from "@/lib/utils";
type Props = { slug: string };
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
export function PublicCafeDetailScreen({ slug }: Props) {
const t = useTranslations("discoverPublic");
const tProfile = useTranslations("discoverProfile");
const locale = useLocale();
const [galleryIndex, setGalleryIndex] = useState(0);
const { data: cafe, isLoading, error } = useQuery({
queryKey: ["public-cafe", slug],
queryFn: () => fetchPublicCafe(slug),
});
const { data: reviews = [] } = useQuery({
queryKey: ["public-cafe-reviews", slug],
queryFn: () => fetchPublicCafeReviews(slug),
enabled: !!slug,
});
const label = (key: string) => {
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers"] as const;
for (const g of groups) {
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
}
return key;
};
const mapSrc =
cafe?.address || cafe?.city
? `https://map.neshan.org/search?term=${encodeURIComponent(
[cafe.address, cafe.city].filter(Boolean).join("، ")
)}`
: null;
if (isLoading) {
return (
<div className="flex min-h-svh items-center justify-center bg-[#f5f5f4]">
<p className="text-sm text-muted-foreground">{t("loading")}</p>
</div>
);
}
if (error || !cafe) {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6">
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
<Button asChild variant="outline">
<Link href={`/${locale}/discover`}>{t("backToList")}</Link>
</Button>
</div>
);
}
// Build image list: gallery first, then cover/logo fallback
const allImages = cafe.galleryUrls?.length
? cafe.galleryUrls
: [cafe.coverImageUrl ?? cafe.logoUrl].filter(Boolean) as string[];
const currentImage = resolveMediaUrl(allImages[galleryIndex] ?? null);
const profile = cafe.discoverProfile;
const allTags = [
...profile.occasions,
...profile.vibes,
...profile.spaceFeatures,
...profile.themes,
];
return (
<div className="min-h-svh bg-[#f5f5f4]">
<header className="border-b bg-white px-4 py-4">
<Link href={`/${locale}/discover`} className="text-sm text-[#0F6E56] hover:underline">
{t("backToList")}
</Link>
<div className="mt-2 flex items-center gap-2">
<h1 className="text-lg font-medium">{cafe.name}</h1>
{cafe.isOpenNow && (
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("openNowLabel")}
</span>
)}
</div>
{cafe.city && (
<p className="text-sm text-muted-foreground">
{cafe.city}{cafe.address ? `${cafe.address}` : ""}
</p>
)}
</header>
<main className="mx-auto max-w-3xl space-y-4 p-4">
{/* Gallery carousel */}
{allImages.length > 0 && (
<div className="space-y-2">
{currentImage && (
<div
className="h-52 w-full rounded-xl bg-cover bg-center"
style={{ backgroundImage: `url(${currentImage})` }}
/>
)}
{allImages.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{allImages.map((img, i) => {
const url = resolveMediaUrl(img);
return (
<button
key={i}
type="button"
onClick={() => setGalleryIndex(i)}
className={cn(
"h-14 w-20 shrink-0 rounded-lg bg-cover bg-center transition-all cursor-pointer",
i === galleryIndex
? "ring-2 ring-[#0F6E56]"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundImage: url ? `url(${url})` : undefined }}
/>
);
})}
</div>
)}
</div>
)}
{/* Info card */}
<Card className="rounded-xl border border-border/80">
<CardContent className="space-y-3 p-4">
{cafe.averageRating > 0 && (
<p className="text-sm font-medium text-[#0F6E56]">
{formatNumber(cafe.averageRating, locale)} {" "}
{t("reviewCount", { count: cafe.reviewCount })}
</p>
)}
{cafe.description && (
<p className="text-sm leading-relaxed text-muted-foreground">{cafe.description}</p>
)}
<div className="flex flex-wrap gap-1">
{allTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{label(tag)}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Working hours */}
{cafe.workingHours && <WorkingHoursCard hours={cafe.workingHours} t={t} locale={locale} />}
{/* Social links */}
{(cafe.instagramHandle || cafe.websiteUrl) && (
<Card className="rounded-xl border border-border/80">
<CardContent className="flex flex-wrap gap-3 p-4">
{cafe.instagramHandle && (
<a
href={`https://instagram.com/${cafe.instagramHandle}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-pink-400 cursor-pointer"
>
<svg className="h-4 w-4 text-pink-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 1.366.062 2.633.336 3.608 1.31.975.975 1.249 2.242 1.311 3.608.058 1.265.069 1.645.069 4.849 0 3.205-.011 3.584-.069 4.849-.062 1.366-.336 2.633-1.311 3.608-.975.975-2.242 1.249-3.608 1.311-1.266.058-1.644.069-4.85.069-3.204 0-3.584-.011-4.849-.069-1.366-.062-2.633-.336-3.608-1.311-.975-.975-1.249-2.242-1.311-3.608C2.175 15.584 2.163 15.205 2.163 12c0-3.204.012-3.584.07-4.849.062-1.366.336-2.633 1.311-3.608.975-.974 2.242-1.248 3.608-1.31C8.416 2.175 8.796 2.163 12 2.163zm0-2.163C8.741 0 8.333.014 7.053.072 5.197.157 3.355.673 1.965 2.063.573 3.453.157 5.197.072 7.053.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.085 1.856.5 3.598 1.893 4.99C3.355 23.327 5.197 23.843 7.053 23.928 8.333 23.986 8.741 24 12 24s3.667-.014 4.947-.072c1.856-.085 3.598-.501 4.99-1.893 1.393-1.392 1.808-3.134 1.893-4.99.058-1.28.072-1.689.072-4.948 0-3.259-.014-3.667-.072-4.947-.085-1.856-.5-3.598-1.893-4.99C20.645.673 18.803.157 16.947.072 15.667.014 15.259 0 12 0z"/>
<path d="M12 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
</svg>
<span>@{cafe.instagramHandle}</span>
</a>
)}
{cafe.websiteUrl && (
<a
href={cafe.websiteUrl.startsWith("http") ? cafe.websiteUrl : `https://${cafe.websiteUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg border border-border/80 bg-white px-3 py-2 text-sm transition hover:border-blue-400 cursor-pointer"
>
<svg className="h-4 w-4 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
</svg>
<span>{t("websiteLabel")}</span>
</a>
)}
</CardContent>
</Card>
)}
<CoffeeAdvisorPanel cafeSlug={slug} />
{/* Map */}
{mapSrc && (
<Card className="overflow-hidden rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("mapTitle")}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<iframe
title={t("mapTitle")}
src={mapSrc}
className="h-64 w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
<div className="border-t p-3">
<a
href={mapSrc}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-[#0C447C] hover:underline"
>
{t("openInNeshan")}
</a>
</div>
</CardContent>
</Card>
)}
{/* Reviews */}
{reviews.length > 0 && (
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("reviewsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
{reviews.map((r) => (
<div key={r.id} className="space-y-2 border-b border-border/60 pb-3 last:border-0">
<p className="text-sm font-medium">{r.authorName}</p>
<p className="text-xs text-amber-600">{"★".repeat(r.rating)}</p>
{r.comment && <p className="text-sm text-muted-foreground">{r.comment}</p>}
{r.ownerReply && (
<p className="rounded-lg bg-[#E1F5EE] px-3 py-2 text-sm text-[#0F6E56]">
{t("ownerReply")}: {r.ownerReply}
</p>
)}
</div>
))}
</CardContent>
</Card>
)}
<Button asChild className="w-full bg-[#0F6E56]">
<Link href={`/${locale}/discover`}>{t("exploreMore")}</Link>
</Button>
</main>
</div>
);
}
// ── Working hours sub-component ───────────────────────────────────────────────
function WorkingHoursCard({
hours,
t,
locale,
}: {
hours: WorkingHours;
t: ReturnType<typeof useTranslations<"discoverPublic">>;
locale: string;
}) {
// Detect today's day key in Iran time (UTC+3:30)
const iranOffset = 210; // minutes
const iranNow = new Date(Date.now() + iranOffset * 60_000);
const dayIndex = iranNow.getUTCDay(); // 0=Sun ... 6=Sat
const dayKeyMap: Record<number, keyof WorkingHours> = {
6: "sat", 0: "sun", 1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri",
};
const todayKey = dayKeyMap[dayIndex];
const DAY_KEYS: (keyof WorkingHours)[] = ["sat", "sun", "mon", "tue", "wed", "thu", "fri"];
return (
<Card className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("workingHoursTitle")}</CardTitle>
</CardHeader>
<CardContent className="p-0 pb-2">
<table className="w-full text-sm">
<tbody>
{DAY_KEYS.map((day) => {
const schedule = hours[day];
const isToday = day === todayKey;
return (
<tr
key={day}
className={cn(
"border-b border-border/40 last:border-0",
isToday && "bg-[#E1F5EE]/60"
)}
>
<td className={cn(
"px-4 py-2 font-medium",
isToday ? "text-[#0F6E56]" : "text-foreground"
)}>
{t(`days.${day}`)}
{isToday && (
<span className="ms-1.5 text-[10px] font-normal text-[#0F6E56]">
(امروز)
</span>
)}
</td>
<td className="px-4 py-2 text-end text-muted-foreground">
{!schedule || !schedule.isOpen ? (
<span className="text-red-500">{t("closedLabel")}</span>
) : schedule.open && schedule.close ? (
<span dir="ltr">{schedule.open} {schedule.close}</span>
) : (
<span className="text-emerald-600">{t("openNowLabel")}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,521 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import {
fetchDiscoverTaxonomy,
fetchNlpHints,
fetchPublicDiscover,
type DiscoverSearchParams,
type NlpHints,
type PublicCafeDiscover,
} from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
const CITIES = [
{ id: "tehran", query: "تهران" },
{ id: "karaj", query: "کرج" },
] as const;
type FilterKey = "themes" | "vibes" | "occasions" | "spaceFeatures";
function toggle(list: string[], value: string): string[] {
return list.includes(value) ? list.filter((x) => x !== value) : [...list, value];
}
// Count non-empty detected filter fields
function nlpHintCount(h: NlpHints | null): number {
if (!h) return 0;
return (
h.themes.length +
h.vibes.length +
h.occasions.length +
h.spaceFeatures.length +
(h.noiseLevel ? 1 : 0) +
(h.priceTier ? 1 : 0) +
(h.size ? 1 : 0)
);
}
export function PublicDiscoverScreen() {
const t = useTranslations("discoverPublic");
const tProfile = useTranslations("discoverProfile");
const locale = useLocale();
const [city, setCity] = useState<string>("tehran");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [sort, setSort] = useState("rating");
const [themes, setThemes] = useState<string[]>([]);
const [vibes, setVibes] = useState<string[]>([]);
const [occasions, setOccasions] = useState<string[]>([]);
const [spaceFeatures, setSpaceFeatures] = useState<string[]>([]);
const [noise, setNoise] = useState<string | null>(null);
const [priceTier, setPriceTier] = useState<string | null>(null);
const [size, setSize] = useState<string | null>(null);
const [openNow, setOpenNow] = useState(false);
// Debounce the search input for NLP hints
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 600);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [search]);
// Fetch NLP hints whenever the debounced search changes
const { data: nlpHints } = useQuery({
queryKey: ["nlp-hints", debouncedSearch],
queryFn: () => fetchNlpHints(debouncedSearch),
enabled: debouncedSearch.trim().length > 2,
staleTime: 30_000,
});
const cityQuery = CITIES.find((c) => c.id === city)?.query ?? "تهران";
const params: DiscoverSearchParams = useMemo(
() => ({
city: cityQuery,
q: search.trim() || undefined,
sort,
themes: themes.length ? themes : undefined,
vibes: vibes.length ? vibes : undefined,
occasions: occasions.length ? occasions : undefined,
spaceFeatures: spaceFeatures.length ? spaceFeatures : undefined,
noise: noise ?? undefined,
priceTier: priceTier ?? undefined,
size: size ?? undefined,
openNow,
requireProfile: true,
}),
[cityQuery, search, sort, themes, vibes, occasions, spaceFeatures, noise, priceTier, size, openNow]
);
const { data: taxonomy } = useQuery({
queryKey: ["discover-taxonomy"],
queryFn: fetchDiscoverTaxonomy,
});
const { data: cafes = [], isLoading, isFetching } = useQuery({
queryKey: ["public-discover", params],
queryFn: () => fetchPublicDiscover(params),
});
const label = useCallback(
(key: string) => {
const groups = ["themes", "vibes", "occasions", "spaceFeatures", "noiseLevels", "priceTiers", "sizes"] as const;
for (const g of groups) {
try { return tProfile(`${g}.${key}` as "themes.modern"); } catch { /* next */ }
}
return key;
},
[tProfile]
);
const clearAll = () => {
setThemes([]); setVibes([]); setOccasions([]); setSpaceFeatures([]);
setNoise(null); setPriceTier(null); setSize(null); setSearch("");
setOpenNow(false);
};
const filterSection = (
key: FilterKey,
options: string[] | undefined,
active: string[],
setActive: (v: string[]) => void
) => {
if (!options?.length) return null;
return (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t(`filters.${key}`)}
</p>
<div className="flex flex-wrap gap-2">
{options.slice(0, 14).map((opt) => (
<button
key={opt}
type="button"
onClick={() => setActive(toggle(active, opt))}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors active:scale-[0.98] cursor-pointer",
active.includes(opt)
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(opt)}
</button>
))}
</div>
</div>
);
};
const detectedCount = nlpHintCount(nlpHints ?? null);
return (
<div className="min-h-svh bg-[#f5f5f4]">
<header className="border-b bg-white px-4 py-5">
<div className="mx-auto max-w-3xl">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("brand")}
</p>
<h1 className="text-lg font-medium text-foreground">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
</header>
<main className="mx-auto max-w-3xl space-y-4 p-4">
{/* City selector */}
<div className="flex flex-wrap gap-2">
{CITIES.map((c) => (
<Button
key={c.id}
size="sm"
variant={city === c.id ? "default" : "outline"}
className={city === c.id ? "bg-[#0F6E56]" : ""}
onClick={() => setCity(c.id)}
>
{t(`cities.${c.id}`)}
</Button>
))}
</div>
{/* AI smart search */}
<div className="space-y-2">
<div className="relative">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
className="text-end pe-10"
/>
{/* AI spark indicator */}
<span
className={cn(
"pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-sm transition-opacity",
debouncedSearch.trim().length > 2 ? "opacity-100" : "opacity-30"
)}
aria-hidden
>
</span>
</div>
<p className="text-[11px] text-muted-foreground">{t("searchHint")}</p>
{/* Detected filters banner */}
{detectedCount > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE]/60 px-3 py-2">
<span className="text-[11px] font-medium text-[#0F6E56]">
{t("aiDetectedLabel")}
</span>
{nlpHints?.themes.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.vibes.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.occasions.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.spaceFeatures.map((k) => (
<span key={k} className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(k)}
</span>
))}
{nlpHints?.noiseLevel && (
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(nlpHints.noiseLevel)}
</span>
)}
{nlpHints?.priceTier && (
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(nlpHints.priceTier)}
</span>
)}
{nlpHints?.size && (
<span className="rounded-md bg-[#0F6E56]/10 px-2 py-0.5 text-[11px] text-[#0F6E56]">
{label(nlpHints.size)}
</span>
)}
<button
type="button"
onClick={() => setSearch("")}
className="ms-auto text-[11px] text-[#0F6E56] underline cursor-pointer"
>
{t("aiDetectedClear")}
</button>
</div>
)}
</div>
{/* Filter panel */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardContent className="space-y-4 p-4">
{filterSection("occasions", taxonomy?.occasions, occasions, setOccasions)}
{filterSection("vibes", taxonomy?.vibes, vibes, setVibes)}
{filterSection("spaceFeatures", taxonomy?.spaceFeatures, spaceFeatures, setSpaceFeatures)}
{filterSection("themes", taxonomy?.themes, themes, setThemes)}
{/* Size filter — was missing before */}
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("filters.size")}
</p>
<div className="flex flex-wrap gap-2">
{taxonomy?.sizes?.map((s) => (
<button
key={s}
type="button"
onClick={() => setSize(size === s ? null : s)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
size === s
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(s)}
</button>
))}
</div>
</div>
{/* Noise level */}
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("filters.noise")}
</p>
<div className="flex flex-wrap gap-2">
{taxonomy?.noiseLevels?.map((n) => (
<button
key={n}
type="button"
onClick={() => setNoise(noise === n ? null : n)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
noise === n
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(n)}
</button>
))}
</div>
</div>
{/* Price tier */}
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("filters.priceTier")}
</p>
<div className="flex flex-wrap gap-2">
{taxonomy?.priceTiers?.map((p) => (
<button
key={p}
type="button"
onClick={() => setPriceTier(priceTier === p ? null : p)}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs transition-colors cursor-pointer",
priceTier === p
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{label(p)}
</button>
))}
</div>
</div>
{/* Open now toggle + actions */}
<div className="flex flex-wrap items-center gap-3 pt-1">
<button
type="button"
onClick={() => setOpenNow((v) => !v)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer",
openNow
? "border-emerald-500 bg-emerald-50 text-emerald-700"
: "border-border/80 hover:border-emerald-400/60"
)}
>
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
openNow ? "bg-emerald-500" : "bg-muted-foreground/40"
)}
/>
{t("openNow")}
</button>
<Button
size="sm"
variant="outline"
onClick={clearAll}
className="ms-auto"
>
{t("clearFilters")}
</Button>
</div>
</CardContent>
</Card>
{/* Results header */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{isLoading || isFetching
? t("loading")
: t("resultCount", { count: cafes.length })}
</p>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="rounded-lg border border-border/80 bg-white px-2 py-1 text-sm"
>
<option value="rating">{t("sort.rating")}</option>
<option value="reviews">{t("sort.reviews")}</option>
<option value="name">{t("sort.name")}</option>
</select>
</div>
{/* Results */}
{cafes.length === 0 && !isLoading ? (
<Card className="rounded-xl border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">{t("empty")}</p>
</Card>
) : (
<ul className="space-y-3">
{cafes.map((cafe) => (
<CafeDiscoverCard key={cafe.id} cafe={cafe} locale={locale} label={label} t={t} />
))}
</ul>
)}
</main>
</div>
);
}
// ── Card component ────────────────────────────────────────────────────────────
function CafeDiscoverCard({
cafe,
locale,
label,
t,
}: {
cafe: PublicCafeDiscover;
locale: string;
label: (key: string) => string;
t: ReturnType<typeof useTranslations<"discoverPublic">>;
}) {
// Pick the best cover: gallery first, then coverImage, then logo
const firstGallery = cafe.galleryUrls?.[0];
const cover = resolveMediaUrl(firstGallery ?? cafe.coverImageUrl ?? cafe.logoUrl);
const tags = [
...cafe.discoverProfile.occasions.slice(0, 2),
...cafe.discoverProfile.vibes.slice(0, 1),
];
return (
<li>
<Link
href={`/${locale}/discover/${cafe.slug}`}
className="block rounded-xl border border-border/80 bg-white transition-all hover:border-[#0F6E56] hover:shadow-sm active:scale-[0.99] cursor-pointer"
>
{/* Cover image */}
{cover ? (
<div
className="h-32 rounded-t-xl bg-cover bg-center"
style={{ backgroundImage: `url(${cover})` }}
/>
) : (
<div className="flex h-32 items-center justify-center rounded-t-xl bg-muted">
<svg className="h-10 w-10 text-muted-foreground/30" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 19V7a2 2 0 012-2h1V4a1 1 0 012 0v1h10V4a1 1 0 112 0v1h1a2 2 0 012 2v12a2 2 0 01-2 2H4a2 2 0 01-2-2z"/>
</svg>
</div>
)}
<div className="space-y-2 p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="font-medium text-foreground">{cafe.name}</h2>
{/* Gallery count badge */}
{cafe.galleryUrls?.length > 1 && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
+{cafe.galleryUrls.length - 1}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Open/closed badge */}
{cafe.isOpenNow && (
<span className="flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("openNowLabel")}
</span>
)}
{cafe.averageRating > 0 && (
<span className="text-sm font-medium text-[#0F6E56]">
{formatNumber(cafe.averageRating, locale)}
</span>
)}
</div>
</div>
{cafe.city && (
<p className="text-xs text-muted-foreground">
{cafe.city}
{cafe.address ? `${cafe.address}` : ""}
</p>
)}
<div className="flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{label(tag)}
</Badge>
))}
{cafe.discoverProfile.priceTier && (
<Badge className="bg-amber-100 text-[10px] text-amber-900">
{label(cafe.discoverProfile.priceTier)}
</Badge>
)}
</div>
{/* Gallery strip */}
{cafe.galleryUrls?.length > 1 && (
<div className="flex gap-1 overflow-x-auto pb-0.5">
{cafe.galleryUrls.slice(1, 4).map((url, i) => (
<div
key={i}
className="h-10 w-16 shrink-0 rounded bg-cover bg-center"
style={{ backgroundImage: `url(${resolveMediaUrl(url)})` }}
/>
))}
</div>
)}
<p className="text-xs text-[#0F6E56]">{t("viewCafe")} </p>
</div>
</Link>
</li>
);
}
@@ -0,0 +1,358 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiGetPaged, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { isoTodayTehran } from "@/lib/reports/analytics";
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, CardHeader, CardTitle } from "@/components/ui/card";
type Branch = { id: string; name: string };
type ShiftDto = {
id: string;
branchId: string;
status: string;
};
export type ExpenseCategory =
| "Supplies"
| "Utilities"
| "Salary"
| "Rent"
| "Maintenance"
| "Other";
export type ExpenseRow = {
id: string;
cafeId: string;
branchId: string;
shiftId?: string | null;
category: ExpenseCategory;
amount: number;
note?: string | null;
receiptImageUrl?: string | null;
createdByUserId: string;
createdAt: string;
};
const CATEGORIES: ExpenseCategory[] = [
"Supplies",
"Utilities",
"Salary",
"Rent",
"Maintenance",
"Other",
];
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
export function ExpensesScreen() {
const t = useTranslations("expenses");
const tCommon = useTranslations("common");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role ?? "");
const queryClient = useQueryClient();
const today = isoTodayTehran();
const [branchId, setBranchId] = useState<string>("");
const [from, setFrom] = useState(today);
const [to, setTo] = useState(today);
const [showModal, setShowModal] = useState(false);
const [category, setCategory] = useState<ExpenseCategory>("Supplies");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [linkShift, setLinkShift] = useState(true);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
}, [branchId, branches]);
const { data: currentShift } = useQuery({
queryKey: ["shift-current", cafeId, branchId],
queryFn: async () => {
try {
return await apiGet<ShiftDto>(
`/api/cafes/${cafeId}/branches/${branchId}/shifts/current`
);
} catch {
return null;
}
},
enabled: !!cafeId && !!branchId,
});
const listKey = ["expenses", cafeId, branchId, from, to] as const;
const { data: listResponse, isLoading } = useQuery({
queryKey: listKey,
queryFn: () =>
apiGetPaged<ExpenseRow>(
`/api/cafes/${cafeId}/expenses?branchId=${encodeURIComponent(branchId)}&from=${from}&to=${to}&page=1&pageSize=50`
),
enabled: !!cafeId && !!branchId && !!from && !!to,
});
const rows = useMemo(() => listResponse?.items ?? [], [listResponse?.items]);
const totalAmount = useMemo(() => rows.reduce((s, r) => s + r.amount, 0), [rows]);
const createExpense = useMutation({
mutationFn: () =>
apiPost<ExpenseRow>(`/api/cafes/${cafeId}/expenses`, {
branchId,
shiftId: linkShift && currentShift ? currentShift.id : null,
category,
amount: Number(amount),
note: note.trim() || null,
receiptImageUrl: null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: listKey });
setShowModal(false);
setAmount("");
setNote("");
setCategory("Supplies");
},
});
const deleteExpense = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/expenses/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: listKey }),
});
const canDelete = MANAGER_ROLES.has(role);
if (!cafeId) return null;
return (
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
<PageHeader
title={t("title")}
subtitle={t("subtitle")}
action={
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
onClick={() => setShowModal(true)}
disabled={!branchId}
>
<Plus className="ms-2 h-4 w-4" />
{t("addExpense")}
</Button>
}
/>
<Card className="rounded-xl border border-border/80 bg-card">
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
<LabeledField label={t("branch")} htmlFor="exp-branch">
<select
id="exp-branch"
className="h-9 min-w-[10rem] rounded-md border border-input bg-background px-3 text-sm"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("fromDate")} htmlFor="exp-from">
<Input
id="exp-from"
type="date"
dir="ltr"
className="w-40 text-end"
value={from}
max={to}
onChange={(e) => setFrom(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("toDate")} htmlFor="exp-to">
<Input
id="exp-to"
type="date"
dir="ltr"
className="w-40 text-end"
value={to}
min={from}
max={today}
onChange={(e) => setTo(e.target.value)}
/>
</LabeledField>
<div className="ms-auto text-end">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("periodTotal")}
</p>
<p className="text-lg font-semibold text-[#BA7517]">
{formatCurrency(totalAmount, numberLocale)}
</p>
</div>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("listTitle")}</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[28rem] text-sm">
<thead>
<tr className="border-b text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
<th className="py-2 text-start">{t("colDate")}</th>
<th className="py-2 text-start">{t("colCategory")}</th>
<th className="py-2 text-start">{t("colNote")}</th>
<th className="py-2 text-end">{t("colAmount")}</th>
{canDelete ? <th className="py-2 w-10" /> : null}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
{t("loading")}
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={canDelete ? 5 : 4} className="py-4 text-muted-foreground">
{t("empty")}
</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.id} className="border-b border-border/50">
<td className="py-2.5 tabular-nums text-muted-foreground" dir="ltr">
{new Date(row.createdAt).toLocaleString(
locale === "en" ? "en-GB" : "fa-IR",
{ dateStyle: "short", timeStyle: "short" }
)}
</td>
<td className="py-2.5">{t(`categories.${row.category}`)}</td>
<td className="py-2.5 max-w-[12rem] truncate text-muted-foreground">
{row.note ?? "—"}
</td>
<td className="py-2.5 text-end font-medium text-[#BA7517] tabular-nums">
{formatCurrency(row.amount, numberLocale)}
</td>
{canDelete ? (
<td className="py-2.5 text-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[#A32D2D]"
onClick={() => deleteExpense.mutate(row.id)}
disabled={deleteExpense.isPending}
aria-label={tCommon("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
) : null}
</tr>
))
)}
</tbody>
</table>
{listResponse?.meta ? (
<p className="mt-3 text-xs text-muted-foreground">
{t("rowCount", {
count: formatNumber(listResponse.meta.total, numberLocale),
})}
</p>
) : null}
</CardContent>
</Card>
{showModal ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="expense-modal-title"
>
<Card className="w-full max-w-md rounded-xl border border-border/80 bg-card shadow-lg">
<CardHeader>
<CardTitle id="expense-modal-title" className="text-base">
{t("addExpense")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LabeledField label={t("category")} htmlFor="exp-cat">
<select
id="exp-cat"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={category}
onChange={(e) => setCategory(e.target.value as ExpenseCategory)}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`categories.${c}`)}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("amount")} htmlFor="exp-amount">
<Input
id="exp-amount"
type="number"
min={1}
dir="ltr"
className="text-end"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("note")} htmlFor="exp-note">
<Input
id="exp-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("notePlaceholder")}
/>
</LabeledField>
{currentShift ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={linkShift}
onChange={(e) => setLinkShift(e.target.checked)}
/>
{t("linkOpenShift")}
</label>
) : (
<p className="text-xs text-muted-foreground">{t("noOpenShift")}</p>
)}
<div className="flex gap-2 pt-2">
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
disabled={!amount || createExpense.isPending}
onClick={() => createExpense.mutate()}
>
{tCommon("confirm")}
</Button>
<Button variant="outline" onClick={() => setShowModal(false)}>
{tCommon("cancel")}
</Button>
</div>
</CardContent>
</Card>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface Employee {
id: string;
name: string;
phone: string;
role: string;
baseSalary: number;
}
interface Attendance {
id: string;
employeeId: string;
employeeName: string;
date: string;
clockIn?: string;
clockOut?: string;
}
interface LeaveRequest {
id: string;
employeeName: string;
startDate: string;
endDate: string;
reason?: string;
status: string;
}
interface Salary {
id: string;
employeeName: string;
monthYear: string;
netSalary: number;
isPaid: boolean;
}
type Tab = "attendance" | "leave" | "payroll";
export function HrScreen() {
const t = useTranslations("hr");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const userId = useAuthStore((s) => s.user?.userId);
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("attendance");
const [monthYear, setMonthYear] = useState(
new Date().toISOString().slice(0, 7)
);
const { data: employees = [] } = useQuery({
queryKey: ["employees", cafeId],
queryFn: () => apiGet<Employee[]>(`/api/cafes/${cafeId}/employees`),
enabled: !!cafeId,
});
const { data: attendance = [] } = useQuery({
queryKey: ["attendance", cafeId],
queryFn: () => apiGet<Attendance[]>(`/api/cafes/${cafeId}/attendance`),
enabled: !!cafeId && tab === "attendance",
});
const { data: leaves = [] } = useQuery({
queryKey: ["leave-requests", cafeId],
queryFn: () =>
apiGet<LeaveRequest[]>(`/api/cafes/${cafeId}/leave-requests?status=Pending`),
enabled: !!cafeId && tab === "leave",
});
const { data: salaries = [] } = useQuery({
queryKey: ["salaries", cafeId, monthYear],
queryFn: () =>
apiGet<Salary[]>(`/api/cafes/${cafeId}/salaries?monthYear=${monthYear}`),
enabled: !!cafeId && tab === "payroll",
});
const clockIn = useMutation({
mutationFn: () =>
apiPost<Attendance>(`/api/cafes/${cafeId}/employees/${userId}/attendance/clock-in`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["attendance", cafeId] }),
});
const clockOut = useMutation({
mutationFn: () =>
apiPost<Attendance>(`/api/cafes/${cafeId}/employees/${userId}/attendance/clock-out`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["attendance", cafeId] }),
});
const approveLeave = useMutation({
mutationFn: (leaveId: string) =>
apiPatch(`/api/cafes/${cafeId}/leave-requests/${leaveId}/status`, {
status: "Approved",
}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["leave-requests", cafeId] }),
});
const markPaid = useMutation({
mutationFn: (salaryId: string) =>
apiPatch(`/api/cafes/${cafeId}/salaries/${salaryId}/paid`),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["salaries", cafeId, monthYear] }),
});
if (!cafeId || !userId) return null;
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">{t("title")}</h2>
<div className="flex flex-wrap gap-2">
{(["attendance", "leave", "payroll"] as Tab[]).map((key) => (
<Button
key={key}
size="sm"
variant={tab === key ? "default" : "outline"}
onClick={() => setTab(key)}
>
{t(`tabs.${key}`)}
</Button>
))}
</div>
{tab === "attendance" && (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("myAttendance")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button onClick={() => clockIn.mutate()} disabled={clockIn.isPending}>
{t("clockIn")}
</Button>
<Button variant="outline" onClick={() => clockOut.mutate()} disabled={clockOut.isPending}>
{t("clockOut")}
</Button>
</CardContent>
</Card>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{attendance.map((a) => (
<Card key={a.id}>
<CardContent className="space-y-1 pt-4 text-sm">
<p className="font-medium">{a.employeeName}</p>
<p className="text-muted-foreground">{a.date}</p>
<p dir="ltr" className="text-end font-mono text-xs">
{a.clockIn ? new Date(a.clockIn).toLocaleTimeString("fa-IR") : "—"}
{" → "}
{a.clockOut ? new Date(a.clockOut).toLocaleTimeString("fa-IR") : "—"}
</p>
</CardContent>
</Card>
))}
</div>
</div>
)}
{tab === "leave" && (
<div className="space-y-3">
{leaves.length === 0 ? (
<p className="text-muted-foreground">{t("noLeave")}</p>
) : (
leaves.map((l) => (
<Card key={l.id}>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-4">
<div>
<p className="font-medium">{l.employeeName}</p>
<p className="text-sm text-muted-foreground">
{l.startDate} {l.endDate}
</p>
{l.reason && <p className="text-sm">{l.reason}</p>}
</div>
<Button size="sm" onClick={() => approveLeave.mutate(l.id)}>
{t("approve")}
</Button>
</CardContent>
</Card>
))
)}
</div>
)}
{tab === "payroll" && (
<div className="space-y-4">
<LabeledField label={t("monthYear")} htmlFor="hr-month" hint="YYYY-MM" className="max-w-xs">
<Input
id="hr-month"
value={monthYear}
onChange={(e) => setMonthYear(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<p className="text-sm text-muted-foreground">
{t("employeeCount")}: {formatNumber(employees.length)}
</p>
{salaries.map((s) => (
<Card key={s.id}>
<CardContent className="flex items-center justify-between pt-4">
<div>
<p className="font-medium">{s.employeeName}</p>
<p>{formatCurrency(s.netSalary)}</p>
</div>
{s.isPaid ? (
<Badge>{t("paid")}</Badge>
) : (
<Button size="sm" onClick={() => markPaid.mutate(s.id)}>
{t("markPaid")}
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,655 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { Link } from "@/i18n/routing";
import { Pencil } from "lucide-react";
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.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, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
type Ingredient = {
id: string;
name: string;
unit: string;
quantityOnHand: number;
reorderLevel: number;
unitCost: number;
parLevel: number;
lowStockWarningPercent: number;
warningThreshold: number;
stockValueToman: number;
isLowStock: boolean;
};
type MenuItem = { id: string; name: string };
type RecipeLine = {
id: string;
ingredientId: string;
ingredientName: string;
unit: string;
quantityPerUnit: number;
};
type MenuItemRecipe = {
menuItemId: string;
menuItemName: string;
lines: RecipeLine[];
materialCostPerUnitToman: number;
};
type PurchasesSummary = {
totalPaidToman: number;
purchaseCount: number;
recent: {
id: string;
ingredientName: string;
delta: number;
unit: string;
totalPaidToman: number;
createdAt: string;
}[];
};
export function InventoryScreen() {
const t = useTranslations("inventory");
const tCommon = useTranslations("common");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const setBranchId = useBranchStore((s) => s.setBranchId);
const qc = useQueryClient();
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
}, [branchId, branches, setBranchId]);
const [tab, setTab] = useState<"materials" | "recipes">("materials");
const [name, setName] = useState("");
const [unit, setUnit] = useState("گرم");
const [qty, setQty] = useState("500");
const [reorder, setReorder] = useState("50");
const [totalPaid, setTotalPaid] = useState("");
const [parLevel, setParLevel] = useState("500");
const [warningPct, setWarningPct] = useState("20");
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editUnit, setEditUnit] = useState("گرم");
const [editReorder, setEditReorder] = useState("0");
const [editUnitCost, setEditUnitCost] = useState("0");
const [editParLevel, setEditParLevel] = useState("0");
const [editWarningPct, setEditWarningPct] = useState("20");
const [selectedMenuItemId, setSelectedMenuItemId] = useState("");
const [recipeDraft, setRecipeDraft] = useState<RecipeLine[]>([]);
const [newRecipeIngredientId, setNewRecipeIngredientId] = useState("");
const [newRecipeQty, setNewRecipeQty] = useState("10");
const { data: ingredients = [], isLoading } = useQuery({
queryKey: ["inventory", cafeId],
queryFn: () => apiGet<Ingredient[]>(`/api/cafes/${cafeId}/inventory/ingredients`),
enabled: !!cafeId,
});
const monthRange = useMemo(() => {
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), 1);
const pad = (n: number) => String(n).padStart(2, "0");
const fmt = (d: Date) =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return { from: fmt(from), to: fmt(now) };
}, []);
const { data: purchasesSummary } = useQuery({
queryKey: ["inventory", cafeId, "purchases", branchId, monthRange.from, monthRange.to],
queryFn: () =>
apiGet<PurchasesSummary>(
`/api/cafes/${cafeId}/inventory/purchases?branchId=${encodeURIComponent(branchId!)}&from=${monthRange.from}&to=${monthRange.to}`
),
enabled: !!cafeId && !!branchId && tab === "materials",
});
const { data: lowStock = [] } = useQuery({
queryKey: ["inventory", cafeId, "low"],
queryFn: () => apiGet<Ingredient[]>(`/api/cafes/${cafeId}/inventory/low-stock`),
enabled: !!cafeId,
});
const { data: menuItems = [] } = useQuery({
queryKey: ["menu", "items", cafeId],
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
enabled: !!cafeId && tab === "recipes",
});
const { data: recipe, isLoading: recipeLoading } = useQuery({
queryKey: ["inventory", cafeId, "recipe", selectedMenuItemId],
queryFn: () =>
apiGet<MenuItemRecipe>(
`/api/cafes/${cafeId}/inventory/menu-items/${selectedMenuItemId}/recipe`
),
enabled: !!cafeId && !!selectedMenuItemId,
});
const impliedUnitCost = useMemo(() => {
const q = parseFloat(qty) || 0;
const paid = parseFloat(totalPaid) || 0;
if (q > 0 && paid > 0) return paid / q;
return 0;
}, [qty, totalPaid]);
const createIngredient = useMutation({
mutationFn: () => {
const quantity = parseFloat(qty) || 0;
const paid = parseFloat(totalPaid) || 0;
return apiPost(`/api/cafes/${cafeId}/inventory/ingredients`, {
name,
unit: unit.trim() || t("defaultUnit"),
quantityOnHand: quantity,
reorderLevel: parseFloat(reorder) || 0,
unitCost: impliedUnitCost,
parLevel: parseFloat(parLevel) || quantity || 0,
lowStockWarningPercent: parseFloat(warningPct) || 20,
totalPaidToman: paid > 0 ? paid : null,
branchId: paid > 0 ? branchId : null,
});
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
notify.success(t("created"));
},
});
const updateIngredient = useMutation({
mutationFn: (id: string) =>
apiPatch(`/api/cafes/${cafeId}/inventory/ingredients/${id}`, {
name: editName.trim(),
unit: editUnit.trim() || t("defaultUnit"),
reorderLevel: parseFloat(editReorder) || 0,
unitCost: parseFloat(editUnitCost) || 0,
parLevel: parseFloat(editParLevel) || 0,
lowStockWarningPercent: parseFloat(editWarningPct) || 20,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
setEditingId(null);
notify.success(t("updated"));
},
});
const adjustStock = useMutation({
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
delta,
note: delta > 0 ? t("purchaseNote") : t("adjustNote"),
totalPaidToman: delta > 0 ? paid : null,
branchId: delta > 0 ? branchId : null,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
void qc.invalidateQueries({ queryKey: ["inventory", cafeId, "purchases"] });
void qc.invalidateQueries({ queryKey: ["expenses"] });
notify.success(t("adjusted"));
},
onError: () => notify.error(t("purchaseRequired")),
});
const saveRecipe = useMutation({
mutationFn: () =>
apiPut(`/api/cafes/${cafeId}/inventory/menu-items/${selectedMenuItemId}/recipe`, {
lines: recipeDraft.map((l) => ({
ingredientId: l.ingredientId,
quantityPerUnit: l.quantityPerUnit,
})),
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId, "recipe", selectedMenuItemId] });
notify.success(t("recipeSaved"));
},
});
useEffect(() => {
if (recipe?.lines) setRecipeDraft(recipe.lines.map((l) => ({ ...l })));
}, [recipe]);
const startEdit = (ing: Ingredient) => {
setEditingId(ing.id);
setEditName(ing.name);
setEditUnit(ing.unit);
setEditReorder(String(ing.reorderLevel));
setEditUnitCost(String(ing.unitCost));
setEditParLevel(String(ing.parLevel));
setEditWarningPct(String(ing.lowStockWarningPercent));
};
if (!cafeId) return null;
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
{lowStock.length > 0 ? (
<Card className="rounded-xl border border-amber-300 bg-amber-50/80 p-4">
<p className="text-sm font-medium text-amber-900">{t("lowStockAlert")}</p>
<p className="mt-1 text-sm text-amber-800">
{lowStock.map((i) => i.name).join("، ")}
</p>
</Card>
) : null}
{tab === "materials" && branchId && purchasesSummary ? (
<Card className="rounded-xl border border-[#0F6E56]/25 bg-[#E1F5EE]/40 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("purchasesThisMonth")}
</p>
<p className="mt-1 text-lg font-medium text-[#0F6E56]">
{formatNumber(purchasesSummary.totalPaidToman, numberLocale)} ت
</p>
<p className="text-xs text-muted-foreground">
{t("purchaseCount", { count: purchasesSummary.purchaseCount })}
</p>
</div>
<Link
href="/expenses"
className="text-sm font-medium text-[#0C447C] hover:underline"
>
{t("viewInExpenses")}
</Link>
</div>
{purchasesSummary.recent.length > 0 ? (
<ul className="mt-3 space-y-1 border-t border-[#0F6E56]/15 pt-3 text-sm">
{purchasesSummary.recent.slice(0, 5).map((p) => (
<li key={p.id} className="flex justify-between gap-2">
<span>
{p.ingredientName} (+{formatNumber(p.delta, numberLocale)} {p.unit})
</span>
<span className="shrink-0 font-medium">
{formatNumber(p.totalPaidToman, numberLocale)} ت
</span>
</li>
))}
</ul>
) : null}
</Card>
) : null}
{tab === "materials" && !branchId ? (
<p className="text-sm text-amber-800">{t("selectBranchForPurchases")}</p>
) : null}
<div className="flex gap-2">
<Button
variant={tab === "materials" ? "default" : "outline"}
className={tab === "materials" ? "bg-[#0F6E56]" : ""}
onClick={() => setTab("materials")}
>
{t("tabMaterials")}
</Button>
<Button
variant={tab === "recipes" ? "default" : "outline"}
className={tab === "recipes" ? "bg-[#0F6E56]" : ""}
onClick={() => setTab("recipes")}
>
{t("tabRecipes")}
</Button>
</div>
{tab === "materials" ? (
<>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader>
<CardTitle className="text-base">{t("addIngredient")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<LabeledField label={t("name")}>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</LabeledField>
<InventoryUnitField value={unit} onChange={setUnit} id="ingredient-unit-new" />
<LabeledField label={t("quantity")}>
<Input value={qty} onChange={(e) => setQty(e.target.value)} dir="ltr" className="text-end" />
</LabeledField>
<LabeledField label={t("parLevel")}>
<Input value={parLevel} onChange={(e) => setParLevel(e.target.value)} dir="ltr" className="text-end" />
</LabeledField>
<LabeledField label={t("totalPaid")}>
<Input
value={totalPaid}
onChange={(e) => setTotalPaid(e.target.value)}
dir="ltr"
className="text-end"
placeholder="0"
/>
</LabeledField>
{impliedUnitCost > 0 ? (
<p className="text-xs text-muted-foreground sm:col-span-2">
{t("impliedUnitCost")}: {formatNumber(impliedUnitCost, numberLocale)} ت / {unit}
</p>
) : null}
<LabeledField label={t("warningPercent")}>
<Input value={warningPct} onChange={(e) => setWarningPct(e.target.value)} dir="ltr" className="text-end" />
</LabeledField>
<LabeledField label={t("reorderLevel")}>
<Input value={reorder} onChange={(e) => setReorder(e.target.value)} dir="ltr" className="text-end" />
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
disabled={!name.trim()}
onClick={() => createIngredient.mutate()}
>
{tCommon("save")}
</Button>
</CardContent>
</Card>
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : ingredients.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{ingredients.map((ing) => {
const isEditing = editingId === ing.id;
return (
<Card
key={ing.id}
className={cn(
"rounded-xl border shadow-sm",
ing.isLowStock ? "border-amber-300 bg-amber-50/50" : "border-border/80"
)}
>
<CardContent className="space-y-3 p-4">
{isEditing ? (
<>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("editIngredient")}
</p>
<LabeledField label={t("name")}>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</LabeledField>
<InventoryUnitField
value={editUnit}
onChange={setEditUnit}
id={`ingredient-unit-${ing.id}`}
/>
<div className="grid gap-2 sm:grid-cols-2">
<LabeledField label={t("parLevel")}>
<Input
value={editParLevel}
onChange={(e) => setEditParLevel(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("unitCost")}>
<Input
value={editUnitCost}
onChange={(e) => setEditUnitCost(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("warningPercent")}>
<Input
value={editWarningPct}
onChange={(e) => setEditWarningPct(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("reorderLevel")}>
<Input
value={editReorder}
onChange={(e) => setEditReorder(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
</div>
<p className="text-[11px] text-muted-foreground">
{t("quantity")}: {formatNumber(ing.quantityOnHand)} {ing.unit} {" "}
{t("quantityEditHint")}
</p>
<div className="flex gap-2">
<Button
size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!editName.trim() || updateIngredient.isPending}
onClick={() => updateIngredient.mutate(ing.id)}
>
{tCommon("save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setEditingId(null)}
>
{tCommon("cancel")}
</Button>
</div>
</>
) : (
<>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-medium">{ing.name}</h3>
<div className="flex shrink-0 items-center gap-1">
{ing.isLowStock ? (
<Badge variant="outline">{t("lowStock")}</Badge>
) : null}
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
aria-label={t("editIngredient")}
onClick={() => startEdit(ing)}
>
<Pencil className="size-4" />
</Button>
</div>
</div>
<p className="text-sm font-medium text-[#0F6E56]">
{formatNumber(ing.quantityOnHand)} {ing.unit}
</p>
<p className="text-[11px] text-muted-foreground">
{t("warningAt")}: {formatNumber(ing.warningThreshold)} {ing.unit}
<span className="mx-1">·</span>
{t("stockValue")}: {formatNumber(ing.stockValueToman)} ت
</p>
<div className="space-y-2">
<div className="flex flex-wrap items-end gap-2">
<LabeledField label={t("adjustDelta")} className="min-w-0 flex-1">
<Input
inputMode="decimal"
value={adjustQty[ing.id] ?? ""}
onChange={(e) =>
setAdjustQty((s) => ({ ...s, [ing.id]: e.target.value }))
}
dir="ltr"
className="h-9 text-end"
placeholder="+100"
/>
</LabeledField>
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
<LabeledField label={t("totalPaid")} className="min-w-0 flex-1">
<Input
inputMode="decimal"
value={adjustPaid[ing.id] ?? ""}
onChange={(e) =>
setAdjustPaid((s) => ({ ...s, [ing.id]: e.target.value }))
}
dir="ltr"
className="h-9 text-end"
/>
</LabeledField>
) : null}
<Button
size="sm"
variant="outline"
disabled={!branchId && parseFloat(adjustQty[ing.id] ?? "0") > 0}
onClick={() => {
const delta = parseFloat(adjustQty[ing.id] ?? "0");
if (!delta) return;
const paid = parseFloat(adjustPaid[ing.id] ?? "0");
if (delta > 0 && paid <= 0) {
notify.error(t("purchaseRequired"));
return;
}
adjustStock.mutate({
id: ing.id,
delta,
paid: delta > 0 ? paid : undefined,
});
setAdjustQty((s) => ({ ...s, [ing.id]: "" }));
setAdjustPaid((s) => ({ ...s, [ing.id]: "" }));
}}
>
{t("adjust")}
</Button>
</div>
{parseFloat(adjustQty[ing.id] ?? "0") > 0 ? (
<p className="text-[11px] text-muted-foreground">{t("purchaseHint")}</p>
) : null}
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</>
) : (
<Card className="rounded-xl border border-border/80 p-4 space-y-4">
<LabeledField label={t("selectMenuItem")}>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={selectedMenuItemId}
onChange={(e) => {
setSelectedMenuItemId(e.target.value);
setRecipeDraft([]);
}}
>
<option value="">{t("selectMenuItemPlaceholder")}</option>
{menuItems.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</LabeledField>
{selectedMenuItemId && recipe ? (
<>
<p className="text-sm text-muted-foreground">
{t("materialCostPerUnit")}: {formatNumber(recipe.materialCostPerUnitToman)} ت
</p>
<p className="text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
{t("recipeLines")}
</p>
{recipeDraft.map((line, idx) => (
<div key={line.ingredientId} className="flex flex-wrap items-end gap-2">
<span className="text-sm flex-1 min-w-[120px]">
{line.ingredientName} ({line.unit})
</span>
<Input
className="w-28 text-end"
dir="ltr"
value={String(line.quantityPerUnit)}
onChange={(e) => {
const next = [...recipeDraft];
next[idx] = { ...line, quantityPerUnit: parseFloat(e.target.value) || 0 };
setRecipeDraft(next);
}}
/>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setRecipeDraft(recipeDraft.filter((_, i) => i !== idx))}
>
{tCommon("delete")}
</Button>
</div>
))}
<div className="flex flex-wrap items-end gap-2 border-t pt-3">
<select
className="h-10 flex-1 min-w-[140px] rounded-md border border-input px-2 text-sm"
value={newRecipeIngredientId}
onChange={(e) => setNewRecipeIngredientId(e.target.value)}
>
<option value="">{t("pickIngredient")}</option>
{ingredients.map((i) => (
<option key={i.id} value={i.id}>
{i.name} ({i.unit})
</option>
))}
</select>
<Input
className="w-24 text-end"
dir="ltr"
value={newRecipeQty}
onChange={(e) => setNewRecipeQty(e.target.value)}
placeholder={t("perUnit")}
/>
<Button
size="sm"
variant="outline"
onClick={() => {
const ing = ingredients.find((i) => i.id === newRecipeIngredientId);
if (!ing) return;
if (recipeDraft.some((l) => l.ingredientId === ing.id)) return;
setRecipeDraft([
...recipeDraft,
{
id: `draft_${ing.id}`,
ingredientId: ing.id,
ingredientName: ing.name,
unit: ing.unit,
quantityPerUnit: parseFloat(newRecipeQty) || 0,
},
]);
}}
>
{t("addLine")}
</Button>
</div>
<Button
className="bg-[#0F6E56]"
disabled={saveRecipe.isPending}
onClick={() => saveRecipe.mutate()}
>
{t("saveRecipe")}
</Button>
<p className="text-xs text-muted-foreground">{t("recipeHint")}</p>
</>
) : selectedMenuItemId ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : null}
</Card>
)}
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useTranslations } from "next-intl";
import { INVENTORY_UNITS, isKnownInventoryUnit } from "@/lib/inventory-units";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
const CUSTOM_VALUE = "__custom__";
type InventoryUnitFieldProps = {
value: string;
onChange: (unit: string) => void;
id?: string;
};
export function InventoryUnitField({ value, onChange, id }: InventoryUnitFieldProps) {
const t = useTranslations("inventory");
const selectValue = isKnownInventoryUnit(value) ? value : CUSTOM_VALUE;
const showCustom = selectValue === CUSTOM_VALUE;
return (
<div className="space-y-2">
<LabeledField label={t("unit")} htmlFor={id}>
<select
id={id}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={selectValue}
onChange={(e) => {
const next = e.target.value;
if (next === CUSTOM_VALUE) {
onChange(value && !isKnownInventoryUnit(value) ? value : "");
} else {
onChange(next);
}
}}
>
{INVENTORY_UNITS.map((u) => (
<option key={u.value} value={u.value}>
{t(`units.${u.key}`)}
</option>
))}
<option value={CUSTOM_VALUE}>{t("unitCustom")}</option>
</select>
</LabeledField>
{showCustom ? (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("unitCustomPlaceholder")}
/>
) : null}
<p className="text-[11px] leading-relaxed text-muted-foreground">{t("unitsHelp")}</p>
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { apiGet, apiPatch } from "@/lib/api/client";
import type { LiveOrder } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const STATUS_FLOW: Record<string, string> = {
Pending: "Confirmed",
Confirmed: "Preparing",
Preparing: "Ready",
Ready: "Delivered",
};
const statusColors: Record<string, string> = {
Pending: "border-yellow-400 bg-yellow-50",
Confirmed: "border-yellow-400 bg-yellow-50",
Preparing: "border-blue-400 bg-blue-50",
Ready: "border-green-400 bg-green-50",
};
type KdsT = ReturnType<typeof useTranslations<"kds">>;
function statusLabel(t: KdsT, status: string): string {
switch (status) {
case "Pending":
return t("status.Pending");
case "Confirmed":
return t("status.Confirmed");
case "Preparing":
return t("status.Preparing");
case "Ready":
return t("status.Ready");
case "Delivered":
return t("status.Delivered");
case "Cancelled":
return t("status.Cancelled");
default:
return status;
}
}
function advanceLabel(t: KdsT, nextStatus: string): string {
switch (nextStatus) {
case "Confirmed":
return t("advanceTo.Confirmed");
case "Preparing":
return t("advanceTo.Preparing");
case "Ready":
return t("advanceTo.Ready");
case "Delivered":
return t("advanceTo.Delivered");
default:
return t("advance");
}
}
export function KdsScreen() {
const t = useTranslations("kds");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [connected, setConnected] = useState(false);
const { data: orders = [], isLoading } = useQuery({
queryKey: ["orders-live", cafeId],
queryFn: () => apiGet<LiveOrder[]>(`/api/cafes/${cafeId}/orders/live`),
enabled: !!cafeId,
refetchInterval: 30_000,
});
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
}, [queryClient, cafeId]);
useEffect(() => {
if (!cafeId) return;
const token =
typeof window !== "undefined"
? localStorage.getItem("meezi_access_token")
: null;
const baseUrl =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/hubs/kds`, {
accessTokenFactory: () => token ?? "",
})
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinCafe", cafeId))
.then(() => setConnected(true))
.catch(() => setConnected(false));
connection.on("OrderCreated", () => refresh());
connection.on("OrderStatusChanged", () => refresh());
return () => {
void connection.stop();
};
}, [cafeId, refresh]);
const advanceStatus = useMutation({
mutationFn: async ({ orderId, status }: { orderId: string; status: string }) =>
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, { status }),
onSuccess: () => refresh(),
});
if (!cafeId) return null;
const columns = [
{ key: "Pending", label: t("pending"), statuses: ["Pending", "Confirmed"] },
{ key: "Preparing", label: t("preparing"), statuses: ["Preparing"] },
{ key: "Ready", label: t("ready"), statuses: ["Ready"] },
];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">{t("title")}</h2>
<span className="text-xs text-muted-foreground">
{connected ? `${t("live")}` : `${t("polling")}`}
</span>
</div>
{isLoading ? (
<p className="text-muted-foreground">{t("loading")}</p>
) : orders.length === 0 ? (
<p className="text-muted-foreground">{t("noOrders")}</p>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{columns.map((col) => (
<div key={col.key} className="space-y-3">
<h3 className="font-semibold">{col.label}</h3>
{orders
.filter((o) => col.statuses.includes(o.status))
.map((order) => {
const nextStatus = STATUS_FLOW[order.status];
return (
<Card
key={order.id}
className={cn("border-2", statusColors[order.status] ?? "")}
>
<CardHeader className="pb-2">
<CardTitle className="flex justify-between gap-2 text-base">
<span className="min-w-0">
#{formatOrderNumber(order)}
{" · "}
{order.tableNumber
? `${t("table")} ${formatNumber(order.tableNumber, numberLocale)}`
: "—"}
</span>
<span className="shrink-0 text-sm font-normal">
{formatCurrency(order.total, numberLocale)}
</span>
</CardTitle>
<span className="mt-1 inline-flex w-fit rounded-md border border-border/80 bg-card/80 px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{statusLabel(t, order.status)}
</span>
</CardHeader>
<CardContent className="space-y-2">
<ul className="text-sm">
{order.items.map((item) => (
<li key={item.id}>
{formatNumber(item.quantity, numberLocale)}×{" "}
{item.menuItemName}
</li>
))}
</ul>
{nextStatus ? (
<Button
size="sm"
className="w-full"
disabled={advanceStatus.isPending}
onClick={() =>
advanceStatus.mutate({
orderId: order.id,
status: nextStatus,
})
}
>
{advanceLabel(t, nextStatus)}
</Button>
) : null}
</CardContent>
</Card>
);
})}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
type Branch = { id: string; name: string };
type BranchFilterSelectProps = {
value: string | null;
onChange: (branchId: string | null) => void;
includeAll?: boolean;
className?: string;
};
export function BranchFilterSelect({
value,
onChange,
includeAll = true,
className,
}: BranchFilterSelectProps) {
const t = useTranslations("tables");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
if (!cafeId || branches.length === 0) return null;
if (!includeAll && branches.length <= 1) return null;
return (
<select
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
aria-label={t("branchFilter")}
>
{includeAll ? (
<option value="">{t("allBranches")}</option>
) : null}
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
type Branch = { id: string; name: string };
export function BranchSelect({ className }: { className?: string }) {
const t = useTranslations("branches");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const setBranchId = useBranchStore((s) => s.setBranchId);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (branches.length === 0) return;
const valid = branchId && branches.some((b) => b.id === branchId);
if (!valid) setBranchId(branches[0]!.id);
}, [branches, branchId, setBranchId]);
if (!cafeId || branches.length <= 1) return null;
return (
<select
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
value={branchId ?? ""}
onChange={(e) => setBranchId(e.target.value || null)}
aria-label={t("label")}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
);
}
@@ -0,0 +1,111 @@
"use client";
import { useMemo } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import { format } from "date-fns-jalali";
import { Wifi, WifiOff } from "lucide-react";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useLiveClock } from "@/lib/hooks/use-live-clock";
import { useOnlineStatus } from "@/lib/hooks/use-online-status";
import {
formatHeaderJalaliDate,
formatHeaderTime,
isPlanTierKey,
} from "@/lib/format-datetime";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
function HeaderDivider() {
return <div className="mx-3 h-9 w-px shrink-0 bg-border/80" aria-hidden />;
}
/** WiFi + Jalali date/time + plan — grouped at header center; clock is the middle focus. */
export function HeaderCenterCluster() {
const locale = useLocale();
const t = useTranslations("dashboard");
const tPlanNames = useTranslations("settings.plans.names");
const online = useOnlineStatus();
const now = useLiveClock();
const planTier = useAuthStore((s) => s.user?.planTier);
const time = useMemo(() => formatHeaderTime(now, locale), [now, locale]);
const jalaliDate = useMemo(
() => formatHeaderJalaliDate(now, locale),
[now, locale]
);
const planLabel = planTier
? isPlanTierKey(planTier)
? tPlanNames(planTier)
: planTier
: "—";
const planBadgeVariant =
planTier === "Business" || planTier === "Enterprise"
? "default"
: planTier === "Pro"
? "secondary"
: "outline";
return (
<div
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center"
aria-live="polite"
>
<div
className="flex items-center gap-2 px-1"
title={online ? t("online") : t("offline")}
aria-label={online ? t("online") : t("offline")}
>
{online ? (
<Wifi className="h-4 w-4 text-emerald-600" aria-hidden />
) : (
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden />
)}
<span
className={cn(
"hidden text-xs font-medium whitespace-nowrap lg:inline",
online ? "text-emerald-700 dark:text-emerald-500" : "text-muted-foreground"
)}
>
{online ? t("online") : t("offline")}
</span>
</div>
<HeaderDivider />
<div className="flex min-w-[5.5rem] flex-col items-center gap-0.5 px-1 text-center tabular-nums">
<time
className="max-w-[12rem] truncate text-[11px] leading-none text-muted-foreground"
dateTime={format(now, "yyyy-MM-dd")}
dir={locale === "en" ? "ltr" : "rtl"}
>
{jalaliDate}
</time>
<time
className="text-base font-semibold leading-none tracking-tight sm:text-lg"
dateTime={format(now, "HH:mm:ss")}
dir="ltr"
>
{time}
</time>
</div>
<HeaderDivider />
<Link
href="/subscription"
className="pointer-events-auto flex flex-col items-center gap-0.5 rounded-md px-1 py-0.5 transition-colors hover:bg-accent/60"
title={t("viewSubscription")}
>
<span className="text-[10px] font-medium uppercase leading-none tracking-wide text-muted-foreground">
{t("activePlan")}
</span>
<Badge variant={planBadgeVariant} className="text-xs font-semibold">
{planLabel}
</Badge>
</Link>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type PageHeaderProps = {
title: string;
subtitle?: string;
action?: ReactNode;
className?: string;
};
export function PageHeader({ title, subtitle, action, className }: PageHeaderProps) {
return (
<header
className={cn(
"mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
className
)}
>
<div className="text-start">
<h1 className="text-lg font-medium tracking-tight text-foreground">{title}</h1>
{subtitle ? (
<p className="mt-1 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{subtitle}
</p>
) : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</header>
);
}
@@ -0,0 +1,277 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing";
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
import {
NAV_GROUPS,
NAV_GROUPS_STORAGE_KEY,
findNavGroupForPath,
type NavGroupDef,
type NavGroupId,
type NavItemDef,
} from "@/lib/sidebar-nav";
import { useAuthStore } from "@/lib/stores/auth.store";
import { cn } from "@/lib/utils";
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
function readStoredOpenGroups(): OpenGroupsState {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as OpenGroupsState;
} catch {
return {};
}
}
function buildDefaultOpenGroups(): OpenGroupsState {
const stored = readStoredOpenGroups();
const defaults: OpenGroupsState = {};
for (const g of NAV_GROUPS) {
defaults[g.id] = stored[g.id] ?? g.defaultOpen;
}
return defaults;
}
function persistOpenGroups(next: OpenGroupsState): void {
try {
localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
function NavLink({
item,
label,
active,
}: {
item: NavItemDef;
label: string;
active: boolean;
}) {
const Icon = item.icon;
return (
<Link
href={item.href}
className={cn(
"group flex items-center rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0 me-2.5",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)}
/>
<span className="min-w-0 truncate">{label}</span>
</Link>
);
}
function NavGroupSection({
group,
title,
open,
onToggle,
pathname,
role,
branchId,
tItem,
}: {
group: NavGroupDef;
title: string;
open: boolean;
onToggle: () => void;
pathname: string;
role: string | undefined;
branchId: string | null | undefined;
tItem: (key: string) => string;
}) {
const visibleItems = group.items.filter((item) =>
canSeeNavItem(item.key, role, branchId)
);
if (visibleItems.length === 0) return null;
return (
<div className="mb-1">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50 cursor-pointer"
>
<ChevronDown
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
open && "rotate-180"
)}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
{title}
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-200",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="space-y-0.5 pb-1 pt-0.5">
{visibleItems.map((item) => {
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<NavLink
key={item.key}
item={item}
label={tItem(item.key)}
active={active}
/>
);
})}
</div>
</div>
</div>
</div>
);
}
export function Sidebar({ side }: { side: "left" | "right" }) {
const t = useTranslations("nav");
const tGroups = useTranslations("nav.groups");
const tBrand = useTranslations("brand");
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const hasHydrated = useAuthStore((s) => s._hasHydrated);
const role = user?.role;
const branchId = user?.branchId ?? null;
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
const visibleGroups = useMemo(
() =>
NAV_GROUPS.filter((g) => {
if (!canSeeNavGroup(g.id, role, branchId)) return false;
return g.items.some((item) => canSeeNavItem(item.key, role, branchId));
}),
[role, branchId]
);
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
setOpenGroups((prev) => {
const next = { ...prev, [groupId]: open };
persistOpenGroups(next);
return next;
});
}, []);
useEffect(() => {
const activeGroup = findNavGroupForPath(pathname);
if (!activeGroup) return;
setOpenGroups((prev) => {
if (prev[activeGroup]) return prev;
const next = { ...prev, [activeGroup]: true };
persistOpenGroups(next);
return next;
});
}, [pathname]);
return (
<aside
className={cn(
"flex w-56 shrink-0 flex-col bg-background",
"border-border",
side === "right" ? "border-s" : "border-e"
)}
>
{/* Logo */}
<div className="flex h-14 items-center gap-2.5 px-4 border-b border-border">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
</svg>
</div>
<span className="text-sm font-bold tracking-tight text-foreground">
{tBrand("name")}
</span>
</div>
{/* Nav */}
<nav
className="flex-1 overflow-y-auto p-3 space-y-1
[&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-thumb]:bg-border"
aria-label={t("aria")}
>
{!hasHydrated ? (
/* Skeleton — shown for ~50ms until Zustand rehydrates from localStorage.
Prevents the flash where all groups are briefly visible before
permission-based filtering kicks in for branch-scoped accounts. */
<div className="space-y-3 px-1 pt-1">
{[40, 32, 40, 32, 40].map((w, i) => (
<div key={i} className="space-y-1.5">
<div className="h-2 w-20 animate-pulse rounded bg-muted" />
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
<div
key={j}
className={`h-8 animate-pulse rounded-lg bg-muted`}
style={{ width: `${w + j * 4}%` }}
/>
))}
</div>
))}
</div>
) : (
visibleGroups.map((group) => {
const isOpen = openGroups[group.id] ?? group.defaultOpen;
return (
<NavGroupSection
key={group.id}
group={group}
title={tGroups(group.id)}
open={isOpen}
onToggle={() => setGroupOpen(group.id, !isOpen)}
pathname={pathname}
role={role}
branchId={branchId}
tItem={(key) => t(key)}
/>
);
})
)}
</nav>
{/* Footer — user role badge */}
{user && (
<div className="border-t border-border px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-[11px] font-semibold text-primary">
{(user.actor ?? user.role).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">
{user.actor ?? user.userId}
</p>
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
</div>
</div>
</div>
)}
</aside>
);
}
@@ -0,0 +1,102 @@
"use client";
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { useLocale } from "next-intl";
import {
getAllQueueItems,
getQueueCount,
removeQueueItem,
markQueueItemFailed,
} from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client";
/** Manual retry — fires one sync pass immediately (used as onClick). */
async function runManualSync(
setSyncing: (v: boolean) => void,
setQueueCount: (n: number) => void
) {
if (!navigator.onLine) return;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
await removeQueueItem(item.id);
} catch {
await markQueueItemFailed(item.id);
}
}
} finally {
setSyncing(false);
setQueueCount(await getQueueCount());
}
}
export function SyncStatusIndicator() {
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
useSyncQueueStore();
const locale = useLocale();
const isFa = locale !== "en";
const show = !isOnline || queueCount > 0 || isSyncing;
if (!show) return null;
const label = isFa
? !isOnline
? "آفلاین"
: isSyncing
? "همگام‌سازی..."
: `${queueCount} مورد در صف`
: !isOnline
? "Offline"
: isSyncing
? "Syncing..."
: `${queueCount} pending`;
return (
<button
type="button"
onClick={() => void runManualSync(setSyncing, setQueueCount)}
disabled={isSyncing || !isOnline}
title={
isFa
? "برای همگام‌سازی دستی کلیک کنید"
: "Click to retry sync"
}
className={cn(
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
"disabled:cursor-not-allowed",
!isOnline
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
: isSyncing
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300"
)}
>
{!isOnline ? (
<WifiOff className="h-3 w-3 shrink-0" aria-hidden />
) : isSyncing ? (
<RefreshCw className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
) : (
<CloudUpload className="h-3 w-3 shrink-0" aria-hidden />
)}
<span>{label}</span>
</button>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { Link, useRouter, usePathname } from "@/i18n/routing";
import { Pencil, LogOut } from "lucide-react";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
import { NotificationCenter } from "@/components/notifications/notification-center";
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
const locales = ["fa", "ar", "en"] as const;
export function Topbar() {
const t = useTranslations();
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const clearAuth = useAuthStore((s) => s.clearAuth);
const cafeId = useAuthStore((s) => s.user?.cafeId);
const { data: cafeSettings, isLoading, isPending } = useCafeSettings(cafeId);
const cafeDisplayName = cafeSettings?.name ?? t("dashboard.cafeName");
const showNameSkeleton = (isLoading || isPending) && !cafeSettings;
const switchLocale = (next: string) => {
router.replace(pathname, { locale: next });
};
return (
<header className="relative flex h-14 items-center gap-3 border-b border-border bg-background px-4 sm:px-6">
{/* Cafe name */}
<div className="flex min-w-0 flex-1 items-center gap-2">
{showNameSkeleton ? (
<Skeleton className="h-5 w-32 max-w-full" />
) : (
<Link
href="/settings"
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2 py-1 transition-colors hover:bg-accent cursor-pointer"
title={t("dashboard.editCafeSettings")}
>
<h1 className="truncate text-sm font-semibold text-foreground sm:text-base">
{cafeDisplayName}
</h1>
<Pencil
className="h-3 w-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-primary"
aria-hidden
/>
<span className="sr-only">{t("dashboard.editCafeSettings")}</span>
</Link>
)}
</div>
<HeaderCenterCluster />
{/* Actions */}
<div className="flex flex-1 items-center justify-end gap-1.5">
<SyncStatusIndicator />
<NotificationCenter />
{/* Language switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2.5 text-xs cursor-pointer">
{t(`languages.${locale}`)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
{locales.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => switchLocale(code)}
className={locale === code ? "font-semibold text-primary cursor-pointer" : "cursor-pointer"}
>
{t(`languages.${code}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Logout */}
<Button
variant="ghost"
size="sm"
onClick={() => {
clearAuth();
router.push("/login");
}}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive cursor-pointer"
title={t("common.logout")}
>
<LogOut className="h-3.5 w-3.5" aria-hidden />
<span className="sr-only">{t("common.logout")}</span>
</Button>
</div>
</header>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { Clock, X, Zap } from "lucide-react";
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
const STORAGE_KEY = "meezi_trial_banner_v1";
interface TimeLeft {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function calcTimeLeft(): TimeLeft {
const diff = Math.max(0, DEADLINE.getTime() - Date.now());
return {
days: Math.floor(diff / 86_400_000),
hours: Math.floor((diff % 86_400_000) / 3_600_000),
minutes: Math.floor((diff % 3_600_000) / 60_000),
seconds: Math.floor((diff % 60_000) / 1_000),
};
}
function pad(n: number) {
return n.toString().padStart(2, "0");
}
export function TrialCountdownBanner() {
const locale = useLocale();
const router = useRouter();
const isRtl = locale !== "en";
// Start hidden — reveal after mount so we can read localStorage without SSR mismatch
const [visible, setVisible] = useState(false);
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calcTimeLeft);
const [expired, setExpired] = useState(false);
// Hydrate visibility from localStorage
useEffect(() => {
if (localStorage.getItem(STORAGE_KEY) !== "1") {
setVisible(true);
}
}, []);
// Tick every second
useEffect(() => {
if (!visible) return;
const id = setInterval(() => {
const tl = calcTimeLeft();
setTimeLeft(tl);
if (tl.days === 0 && tl.hours === 0 && tl.minutes === 0 && tl.seconds === 0) {
setExpired(true);
}
}, 1_000);
return () => clearInterval(id);
}, [visible]);
if (!visible) return null;
const dismiss = () => {
setVisible(false);
localStorage.setItem(STORAGE_KEY, "1");
};
const urgency = timeLeft.days <= 3; // red when ≤ 3 days left
const soon = timeLeft.days <= 7; // amber when ≤ 7 days left
const bgClass = urgency
? "bg-red-600"
: soon
? "bg-amber-500"
: "bg-[#0F6E56]";
const textFa = expired
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
const textEn = expired
? "Your Meezi trial has ended. Choose a plan to continue."
: "Free trial ends 14 Khordad 1405 (Jun 4)";
const Digit = ({ value, label }: { value: number; label: string }) => (
<div className="flex flex-col items-center">
<span className="min-w-[2.25rem] rounded-md bg-white/20 px-2 py-0.5 text-center text-base font-extrabold tabular-nums leading-tight text-white sm:text-lg">
{pad(value)}
</span>
<span className="mt-0.5 text-[10px] font-medium text-white/70">{label}</span>
</div>
);
const labelsFa = ["روز", "ساعت", "دقیقه", "ثانیه"];
const labelsEn = ["d", "h", "m", "s"];
const labels = isRtl ? labelsFa : labelsEn;
return (
<div
className={`relative flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 sm:px-6 ${bgClass} transition-colors duration-700`}
role="banner"
aria-live="polite"
>
{/* Icon + message */}
<div className="flex items-center gap-2 text-white">
<Clock className="h-4 w-4 shrink-0 opacity-80" />
<span className="text-xs font-semibold sm:text-sm">
{isRtl ? textFa : textEn}
</span>
</div>
{/* Countdown digits */}
{!expired && (
<div className="flex items-end gap-2">
<Digit value={timeLeft.days} label={labels[0]} />
<span className="mb-3 text-white/60 font-bold">:</span>
<Digit value={timeLeft.hours} label={labels[1]} />
<span className="mb-3 text-white/60 font-bold">:</span>
<Digit value={timeLeft.minutes} label={labels[2]} />
<span className="mb-3 text-white/60 font-bold">:</span>
<Digit value={timeLeft.seconds} label={labels[3]} />
</div>
)}
{/* CTA */}
<button
onClick={() => router.push("/subscription")}
className="ms-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-gray-900 shadow-sm transition hover:bg-gray-100 active:scale-95"
>
<Zap className="h-3.5 w-3.5 text-amber-500" />
{isRtl ? "ارتقا به پرو" : "Upgrade to Pro"}
</button>
{/* Dismiss */}
<button
onClick={dismiss}
className="shrink-0 rounded p-0.5 text-white/70 transition hover:text-white"
aria-label={isRtl ? "بستن" : "Dismiss"}
>
<X className="h-4 w-4" />
</button>
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useRef } from "react";
import { ImagePlus, Video } from "lucide-react";
import { useTranslations } from "next-intl";
import { apiUpload, resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type MediaKind = "menu" | "table";
type MediaPairUploadProps = {
cafeId: string;
kind: MediaKind;
imageUrl?: string | null;
videoUrl?: string | null;
onImageChange: (url: string | null) => void;
onVideoChange: (url: string | null) => void;
className?: string;
};
export function MediaPairUpload({
cafeId,
kind,
imageUrl,
videoUrl,
onImageChange,
onVideoChange,
className,
}: MediaPairUploadProps) {
const t = useTranslations("media");
const imageRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLInputElement>(null);
const imageEndpoint =
kind === "menu"
? `/api/cafes/${cafeId}/media/menu-image`
: `/api/cafes/${cafeId}/media/table-image`;
const videoEndpoint =
kind === "menu"
? `/api/cafes/${cafeId}/media/menu-video`
: `/api/cafes/${cafeId}/media/table-video`;
const imgSrc = resolveMediaUrl(imageUrl);
const vidSrc = resolveMediaUrl(videoUrl);
const upload = async (file: File, endpoint: string, onDone: (url: string) => void) => {
const data = await apiUpload<{ url: string }>(endpoint, file);
onDone(data.url);
};
return (
<div className={cn("space-y-2", className)}>
<div className="flex flex-wrap gap-2">
<input
ref={imageRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f, imageEndpoint, onImageChange);
}}
/>
<input
ref={videoRef}
type="file"
accept="video/mp4,video/webm,video/quicktime"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f, videoEndpoint, onVideoChange);
}}
/>
<Button type="button" size="sm" variant="outline" onClick={() => imageRef.current?.click()}>
<ImagePlus className="me-1 h-3.5 w-3.5" />
{t("uploadImage")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => videoRef.current?.click()}>
<Video className="me-1 h-3.5 w-3.5" />
{t("uploadVideo")}
</Button>
{imageUrl ? (
<Button type="button" size="sm" variant="ghost" onClick={() => onImageChange(null)}>
{t("removeImage")}
</Button>
) : null}
{videoUrl ? (
<Button type="button" size="sm" variant="ghost" onClick={() => onVideoChange(null)}>
{t("removeVideo")}
</Button>
) : null}
</div>
<div className="flex flex-wrap gap-3">
{imgSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imgSrc}
alt=""
className="h-20 w-20 rounded-lg border object-cover"
/>
) : null}
{vidSrc ? (
<video
src={vidSrc}
className="h-20 max-w-[140px] rounded-lg border object-cover"
muted
playsInline
controls
/>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useRef } from "react";
import { Box } from "lucide-react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { apiGet, apiUpload, ApiClientError } from "@/lib/api/client";
import { MENU_3D_GLB_MAX_MB, MENU_360_PHOTO_COUNT } from "@/lib/menu-3d";
import { Button } from "@/components/ui/button";
type Menu3dUploadProps = {
cafeId: string;
model3dUrl?: string | null;
onChange: (url: string | null) => void;
};
export function Menu3dUpload({ cafeId, model3dUrl, onChange }: Menu3dUploadProps) {
const t = useTranslations("media");
const tSub = useTranslations("subscription");
const ref = useRef<HTMLInputElement>(null);
const { data: billing } = useQuery({
queryKey: ["billing-status", cafeId],
queryFn: () => apiGet<{ menu3dEnabled: boolean }>("/api/billing/status"),
enabled: !!cafeId,
});
const enabled = billing?.menu3dEnabled ?? false;
return (
<div className="space-y-2 rounded-lg border border-dashed border-border/80 bg-muted/20 p-3">
<p className="text-xs font-medium text-foreground">{t("upload3dTitle")}</p>
<p className="text-xs text-muted-foreground">{t("upload3dHint", { maxMb: MENU_3D_GLB_MAX_MB })}</p>
{!enabled ? (
<p className="text-xs text-amber-700">{tSub("featureMenu3dUpgrade")}</p>
) : null}
<p className="text-[11px] text-muted-foreground">
{t("upload3dPhotoCount", {
min: MENU_360_PHOTO_COUNT.min,
ideal: MENU_360_PHOTO_COUNT.ideal,
})}
</p>
<div className="flex flex-wrap gap-2">
<input
ref={ref}
type="file"
accept=".glb,model/gltf-binary"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (!f) return;
void apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/menu-model3d`, f)
.then((d) => onChange(d.url))
.catch((err) => {
if (err instanceof ApiClientError && err.code === "PLAN_FEATURE_DISABLED") {
alert(tSub("featureMenu3dUpgrade"));
}
});
}}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={!enabled}
onClick={() => ref.current?.click()}
>
<Box className="me-1 h-3.5 w-3.5" />
{t("upload3d")}
</Button>
{model3dUrl ? (
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(null)}>
{t("remove3d")}
</Button>
) : null}
</div>
{model3dUrl ? (
<p className="text-[11px] text-[#0F6E56]">{t("upload3dReady")}</p>
) : null}
</div>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { notify } from "@/lib/notify";
type MenuAi3dGenerateProps = {
cafeId: string;
itemId: string;
imageUrl?: string | null;
onGenerated: (model3dUrl: string) => void;
};
type BillingStatus = {
menu3dEnabled: boolean;
menuAi3dEnabled: boolean;
menuAi3dUsedThisMonth: number;
menuAi3dMonthlyLimit: number;
};
type Ai3dUsage = {
used: number;
limit: number;
period: string;
};
export function MenuAi3dGenerate({
cafeId,
itemId,
imageUrl,
onGenerated,
}: MenuAi3dGenerateProps) {
const t = useTranslations("media");
const tSub = useTranslations("subscription");
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const { data: billing } = useQuery({
queryKey: ["billing-status", cafeId],
queryFn: () => apiGet<BillingStatus>("/api/billing/status"),
enabled: !!cafeId,
});
const { data: usage } = useQuery({
queryKey: ["menu-ai-3d-usage", cafeId],
queryFn: () => apiGet<Ai3dUsage>(`/api/cafes/${cafeId}/menu/ai-3d/usage`),
enabled: !!cafeId && (billing?.menuAi3dEnabled ?? false),
});
const aiEnabled = billing?.menuAi3dEnabled ?? false;
const used = usage?.used ?? billing?.menuAi3dUsedThisMonth ?? 0;
const limit = usage?.limit ?? billing?.menuAi3dMonthlyLimit ?? 100;
const atLimit = limit > 0 && used >= limit;
const generate = useMutation({
mutationFn: () =>
apiPost<{ model3dUrl: string; used: number; limit: number }>(
`/api/cafes/${cafeId}/menu/items/${itemId}/ai-3d`,
{}
),
onSuccess: (data) => {
onGenerated(data.model3dUrl);
void queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
void queryClient.invalidateQueries({ queryKey: ["menu-ai-3d-usage", cafeId] });
notify.success(t("ai3dSuccess"));
},
onError: (err) => {
if (err instanceof ApiClientError) {
if (err.code === "PLAN_FEATURE_DISABLED") {
notify.error(tSub("featureMenuAi3dUpgrade"));
return;
}
if (err.code === "PLAN_LIMIT_REACHED") {
notify.error(t("ai3dLimitReached"));
return;
}
if (err.code === "NO_IMAGE") {
notify.error(t("ai3dNoImage"));
return;
}
}
notify.error(t("ai3dFailed"));
},
});
const handleClick = async () => {
setBusy(true);
try {
await generate.mutateAsync();
} finally {
setBusy(false);
}
};
if (!billing?.menu3dEnabled) return null;
return (
<div className="space-y-2 rounded-lg border border-border/80 bg-card p-3">
<p className="text-xs font-medium text-foreground">{t("ai3dTitle")}</p>
<p className="text-xs text-muted-foreground">{t("ai3dHint")}</p>
{!aiEnabled ? (
<p className="text-xs text-amber-700">{tSub("featureMenuAi3dUpgrade")}</p>
) : (
<p className="text-[11px] text-muted-foreground">
{t("ai3dUsage", { used: used.toLocaleString("fa-IR"), limit: limit.toLocaleString("fa-IR") })}
</p>
)}
<Button
type="button"
size="sm"
variant="default"
className="bg-primary text-primary-foreground hover:opacity-90"
disabled={!aiEnabled || !imageUrl || atLimit || busy || generate.isPending}
onClick={() => void handleClick()}
>
<Sparkles className="me-1 h-3.5 w-3.5" />
{busy || generate.isPending ? t("ai3dGenerating") : t("ai3dGenerate")}
</Button>
{!imageUrl ? (
<p className="text-[11px] text-amber-700">{t("ai3dNoImage")}</p>
) : null}
</div>
);
}
@@ -0,0 +1,235 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Lock } from "lucide-react";
import { ApiClientError } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency } from "@/lib/format";
import {
deleteBranchMenuOverride,
getBranchMenu,
upsertBranchMenuOverride,
type BranchMenuItem,
} from "@/lib/api/branch-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { Alert } from "@/components/ui/alert";
import { useConfirm } from "@/components/providers/confirm-provider";
type BranchMenuOverridesProps = {
cafeId: string;
branchId: string;
numberLocale: string;
};
export function BranchMenuOverrides({
cafeId,
branchId,
numberLocale,
}: BranchMenuOverridesProps) {
const t = useTranslations("branchMenu");
const tErrors = useTranslations("errors");
const planTier = useAuthStore((s) => s.user?.planTier ?? "Free");
const canOverridePrice = planTier !== "Free";
const queryClient = useQueryClient();
const confirmDialog = useConfirm();
const [message, setMessage] = useState<string | null>(null);
const [priceDraft, setPriceDraft] = useState<Record<string, string>>({});
const { data: rows = [], isLoading } = useQuery({
queryKey: ["branch-menu", cafeId, branchId, "manage"],
queryFn: () => getBranchMenu(cafeId, branchId, { includeUnavailable: true }),
enabled: !!cafeId && !!branchId,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["branch-menu", cafeId, branchId] });
queryClient.invalidateQueries({ queryKey: ["menu-items", cafeId] });
};
const upsert = useMutation({
mutationFn: ({
menuItemId,
isAvailable,
priceOverride,
}: {
menuItemId: string;
isAvailable: boolean;
priceOverride: number | null;
}) =>
upsertBranchMenuOverride(cafeId, branchId, menuItemId, {
isAvailable,
priceOverride,
}),
onSuccess: () => {
setMessage(null);
invalidate();
},
onError: (err: Error) => {
if (err instanceof ApiClientError && err.code === "PLAN_LIMIT_REACHED") {
setMessage(t("priceOverridePro"));
return;
}
setMessage(tErrors("planLimit"));
},
});
const resetOverride = useMutation({
mutationFn: (menuItemId: string) =>
deleteBranchMenuOverride(cafeId, branchId, menuItemId),
onSuccess: () => invalidate(),
});
const sorted = useMemo(
() => [...rows].sort((a, b) => a.name.localeCompare(b.name, "fa")),
[rows]
);
const handleToggle = (row: BranchMenuItem) => {
upsert.mutate({
menuItemId: row.id,
isAvailable: !row.isAvailable,
priceOverride: row.hasPriceOverride ? row.effectivePrice : null,
});
};
const handleSavePrice = (row: BranchMenuItem) => {
const raw = priceDraft[row.id] ?? String(row.effectivePrice);
const parsed = Number(raw.replace(/,/g, ""));
if (!Number.isFinite(parsed) || parsed < 0) return;
upsert.mutate({
menuItemId: row.id,
isAvailable: row.isAvailable,
priceOverride: parsed,
});
};
const handleReset = async (row: BranchMenuItem) => {
if (!row.isOverridden) return;
const ok = await confirmDialog({ description: t("confirmReset") });
if (!ok) return;
resetOverride.mutate(row.id);
};
if (isLoading) {
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
}
return (
<div className="space-y-3">
{message ? (
<Alert variant="warning" onDismiss={() => setMessage(null)}>
{message}
</Alert>
) : null}
<div className="overflow-x-auto rounded-xl border border-border/80 bg-card">
<table className="w-full min-w-[32rem] text-start text-sm">
<thead>
<tr className="border-b border-border/80 bg-muted/30 text-[11px] uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2 font-medium">{t("name")}</th>
<th className="px-3 py-2 font-medium">{t("masterPrice")}</th>
<th className="px-3 py-2 font-medium">{t("branchPrice")}</th>
<th className="px-3 py-2 font-medium">{t("availability")}</th>
<th className="px-3 py-2 font-medium">{t("actions")}</th>
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr
key={row.id}
className={cn(
"border-b border-border/60 last:border-0",
row.isOverridden && "bg-[#E1F5EE]/40"
)}
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<MenuItemLabels item={row} lines={1} primaryClassName="text-sm" />
{row.isOverridden ? (
<Badge variant="outline" className="text-[10px]">
{t("overrideActive")}
</Badge>
) : null}
</div>
</td>
<td className="px-3 py-2 text-muted-foreground">
{formatCurrency(row.masterPrice, numberLocale)}
</td>
<td className="px-3 py-2">
{canOverridePrice ? (
<div className="flex items-center gap-1">
<Input
className="h-8 w-28 text-xs"
value={priceDraft[row.id] ?? String(row.effectivePrice)}
onChange={(e) =>
setPriceDraft((d) => ({ ...d, [row.id]: e.target.value }))
}
/>
<Button
type="button"
size="sm"
variant="outline"
className="h-8 text-xs"
disabled={upsert.isPending}
onClick={() => handleSavePrice(row)}
>
{t("savePrice")}
</Button>
</div>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" aria-hidden />
{t("priceOverridePro")}
</span>
)}
</td>
<td className="px-3 py-2">
<button
type="button"
role="switch"
aria-checked={row.isAvailable}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
row.isAvailable
? "border-primary bg-primary"
: "border-border bg-muted"
)}
onClick={() => handleToggle(row)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 translate-y-0.5 rounded-full bg-white shadow transition-transform",
row.isAvailable ? "translate-x-5" : "translate-x-0.5"
)}
/>
</button>
<span className="ms-2 text-xs text-muted-foreground">
{row.isAvailable ? t("available") : t("unavailable")}
</span>
</td>
<td className="px-3 py-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 text-xs"
disabled={!row.isOverridden || resetOverride.isPending}
onClick={() => handleReset(row)}
>
{t("resetOverride")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { useTranslations } from "next-intl";
import { CATEGORY_EMOJI_GROUPS } from "@/lib/category-emoji-presets";
import { cn } from "@/lib/utils";
type CategoryEmojiPickerProps = {
value: string;
onChange: (emoji: string) => void;
className?: string;
};
export function CategoryEmojiPicker({ value, onChange, className }: CategoryEmojiPickerProps) {
const t = useTranslations("menuAdmin");
return (
<div className={cn("space-y-3 max-h-[min(420px,50vh)] overflow-y-auto pe-1", className)}>
{CATEGORY_EMOJI_GROUPS.map((group) => (
<div key={group.id} className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t(`iconEmojiGroups.${group.id}`)}
</p>
<div className="flex flex-wrap gap-1.5">
{group.emojis.map((emoji, index) => {
const selected = value.trim() === emoji;
return (
<button
key={`${group.id}-${index}-${emoji}`}
type="button"
title={emoji}
onClick={() => onChange(emoji)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg border text-lg transition-all active:scale-[0.96]",
selected
? "border-[#0F6E56] bg-[#E1F5EE] ring-1 ring-[#0F6E56]/30"
: "border-border/80 bg-card hover:border-[#0F6E56]/40 hover:bg-muted/40"
)}
>
{emoji}
</button>
);
})}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,180 @@
"use client";
import { useRef, useState } from "react";
import { ImagePlus } from "lucide-react";
import { useTranslations } from "next-intl";
import {
CategoryPresetPicker,
type CategoryIconSelection,
} from "@/components/menu/category-preset-picker";
import { CategoryEmojiPicker } from "@/components/menu/category-emoji-picker";
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
import { CategoryVisual } from "@/components/menu/category-visual";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { apiUpload, resolveMediaUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { cn } from "@/lib/utils";
type CategoryMediaFieldsProps = {
cafeId: string;
icon: string;
iconPresetId: string | null;
iconStyle: string | null;
imageUrl: string;
onIconChange: (value: string) => void;
onPresetChange: (value: CategoryIconSelection) => void;
onImageChange: (url: string | null) => void;
className?: string;
};
type MediaTab = "preset" | "emoji" | "image";
export function CategoryMediaFields({
cafeId,
icon,
iconPresetId,
iconStyle,
imageUrl,
onIconChange,
onPresetChange,
onImageChange,
className,
}: CategoryMediaFieldsProps) {
const t = useTranslations("menuAdmin");
const tMedia = useTranslations("media");
const imageRef = useRef<HTMLInputElement>(null);
const imgSrc = resolveMediaUrl(imageUrl);
const [tab, setTab] = useState<MediaTab>(
imageUrl ? "image" : iconPresetId ? "preset" : "preset"
);
const uploadImage = async (file: File) => {
const data = await apiUpload<{ url: string }>(
`/api/cafes/${cafeId}/media/menu-image`,
file
);
onImageChange(data.url);
onPresetChange({ iconPresetId: null, iconStyle: null });
onIconChange("");
setTab("image");
};
const tabs: { id: MediaTab; label: string }[] = [
{ id: "preset", label: t("iconTabPreset") },
{ id: "emoji", label: t("iconTabEmoji") },
{ id: "image", label: t("iconTabImage") },
];
return (
<div className={cn("space-y-3", className)}>
<div className="flex flex-wrap gap-1.5">
{tabs.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
tab === id
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 text-muted-foreground hover:border-[#0F6E56]/40"
)}
>
{label}
</button>
))}
</div>
{tab === "preset" ? (
<CategoryPresetPicker
value={{
iconPresetId,
iconStyle: (iconStyle as CategoryIconSelection["iconStyle"]) ?? DEFAULT_CATEGORY_ICON_STYLE,
}}
onChange={(next) => {
onPresetChange(next);
if (next.iconPresetId) onIconChange("");
onImageChange(null);
}}
/>
) : null}
{tab === "emoji" ? (
<div className="space-y-3 rounded-lg border border-border/80 bg-muted/20 p-3">
{icon.trim() ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t("iconPreview")}</span>
<CategoryVisual icon={icon} size="md" />
<button
type="button"
className="text-[#0F6E56] underline-offset-2 hover:underline"
onClick={() => onIconChange("")}
>
{t("clearIconEmoji")}
</button>
</div>
) : null}
<CategoryEmojiPicker
value={icon}
onChange={(emoji) => {
onIconChange(emoji);
onPresetChange({ iconPresetId: null, iconStyle: null });
onImageChange(null);
}}
/>
<LabeledField label={t("categoryIconCustom")} htmlFor="cat-icon" className="max-w-[10rem]">
<Input
id="cat-icon"
value={icon}
onChange={(e) => {
onIconChange(e.target.value);
if (e.target.value.trim()) {
onPresetChange({ iconPresetId: null, iconStyle: null });
onImageChange(null);
}
}}
placeholder="☕"
className="text-center text-lg"
maxLength={16}
/>
</LabeledField>
</div>
) : null}
{tab === "image" ? (
<div className="space-y-2">
<input
ref={imageRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadImage(f);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => imageRef.current?.click()}>
<ImagePlus className="me-1 h-3.5 w-3.5" />
{t("categoryImage")}
</Button>
{imageUrl ? (
<Button type="button" size="sm" variant="ghost" onClick={() => onImageChange(null)}>
{tMedia("removeImage")}
</Button>
) : null}
{iconPresetId ? (
<CategoryPresetIcon presetId={iconPresetId} style={iconStyle} size="sm" />
) : null}
</div>
{imgSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgSrc} alt="" className="h-16 w-16 rounded-lg border object-cover" />
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,167 @@
"use client";
import type { CSSProperties } from "react";
import {
DEFAULT_CATEGORY_ICON_STYLE,
getCategoryIconPreset,
getCategoryIconStroke,
isCategoryIconStyle,
type CategoryIconStyleId,
} from "@/lib/category-icon-presets";
import type { CafeThemePalette } from "@/lib/cafe-theme";
import { cn } from "@/lib/utils";
type CategoryPresetIconProps = {
presetId: string;
style?: string | null;
size?: "xs" | "sm" | "md";
className?: string;
/** When set (QR guest menu), icon shell uses café theme instead of default Meezi green. */
brandColors?: Pick<
CafeThemePalette,
"primary" | "secondary" | "accent" | "surface" | "textMuted"
>;
};
function themedIconShellStyle(
styleId: CategoryIconStyleId,
c: NonNullable<CategoryPresetIconProps["brandColors"]>
): CSSProperties {
const primaryRing = `0 0 0 2px color-mix(in srgb, ${c.primary} 20%, transparent)`;
switch (styleId) {
case "flat":
return {
backgroundColor: c.secondary,
color: c.primary,
border: `1px solid color-mix(in srgb, ${c.primary} 15%, transparent)`,
};
case "modern":
return {
background: `linear-gradient(135deg, ${c.secondary}, ${c.surface}, color-mix(in srgb, ${c.secondary} 60%, transparent))`,
color: c.primary,
border: `1px solid color-mix(in srgb, ${c.primary} 20%, transparent)`,
};
case "minimal":
return { backgroundColor: "transparent", color: c.textMuted, border: "1px solid transparent" };
case "outline":
return {
backgroundColor: c.surface,
color: c.primary,
border: `2px solid color-mix(in srgb, ${c.primary} 35%, transparent)`,
};
case "soft":
return {
backgroundColor: `color-mix(in srgb, ${c.secondary} 70%, transparent)`,
color: c.primary,
border: "none",
borderRadius: "0.75rem",
};
case "bold":
return { backgroundColor: c.primary, color: "#fff", border: "none" };
case "gradient":
return {
background: `linear-gradient(to top right, ${c.primary}, color-mix(in srgb, ${c.primary} 75%, ${c.accent}), color-mix(in srgb, ${c.primary} 40%, #fff))`,
color: "#fff",
border: "none",
};
case "pastel":
return {
backgroundColor: `color-mix(in srgb, ${c.secondary} 40%, ${c.surface})`,
color: c.accent,
border: `1px solid color-mix(in srgb, ${c.accent} 20%, transparent)`,
};
case "duotone":
return {
backgroundColor: c.secondary,
color: c.accent,
border: `1px solid color-mix(in srgb, ${c.accent} 15%, transparent)`,
boxShadow: primaryRing,
};
default:
return { backgroundColor: c.secondary, color: c.primary };
}
}
const boxSize = {
xs: "h-5 w-5",
sm: "h-7 w-7",
md: "h-10 w-10",
} as const;
const iconSize = {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-5 w-5",
} as const;
function resolveStyle(style: string | null | undefined): CategoryIconStyleId {
return isCategoryIconStyle(style) ? style : DEFAULT_CATEGORY_ICON_STYLE;
}
const styleShell: Record<CategoryIconStyleId, string> = {
flat: "bg-[#E1F5EE] text-[#0F6E56] border border-[#0F6E56]/15",
modern:
"bg-gradient-to-br from-[#E1F5EE] via-white to-[#E1F5EE]/60 text-[#0F6E56] border border-[#0F6E56]/20 shadow-sm",
minimal: "bg-transparent text-muted-foreground border border-transparent",
outline: "bg-white text-[#0F6E56] border-2 border-[#0F6E56]/35",
real: "bg-muted/30 border border-border/80 overflow-hidden p-0",
soft: "bg-[#E1F5EE]/70 text-[#0F6E56] border-0 shadow-md rounded-xl",
bold: "bg-[#0F6E56] text-white border-0 shadow-sm",
gradient:
"bg-gradient-to-tr from-[#0F6E56] via-[#1a8f6e] to-[#5ec4a8] text-white border-0 shadow-md",
pastel: "bg-[#FDF8F3] text-[#BA7517] border border-[#BA7517]/20",
duotone: "bg-[#E1F5EE] text-[#0C447C] border border-[#0C447C]/15 ring-2 ring-[#0F6E56]/20",
};
export function CategoryPresetIcon({
presetId,
style,
size = "sm",
className,
brandColors,
}: CategoryPresetIconProps) {
const preset = getCategoryIconPreset(presetId);
if (!preset) return null;
const styleId = resolveStyle(style);
const Icon = preset.icon;
const stroke = getCategoryIconStroke(styleId);
if (styleId === "real") {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preset.realImageUrl}
alt=""
className={cn(
"shrink-0 rounded-md object-cover",
boxSize[size],
styleShell.real,
className
)}
/>
);
}
const shellStyle = brandColors ? themedIconShellStyle(styleId, brandColors) : undefined;
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-md",
boxSize[size],
!brandColors && styleShell[styleId],
className
)}
style={shellStyle}
aria-hidden
>
<Icon
className={cn(iconSize[size], stroke.className)}
strokeWidth={stroke.strokeWidth}
fill={styleId === "bold" || styleId === "gradient" ? "currentColor" : "none"}
fillOpacity={styleId === "bold" || styleId === "gradient" ? 0.15 : 0}
/>
</span>
);
}
@@ -0,0 +1,126 @@
"use client";
import { useTranslations } from "next-intl";
import {
CATEGORY_ICON_PRESETS,
CATEGORY_ICON_STYLES,
DEFAULT_CATEGORY_ICON_STYLE,
type CategoryIconPresetKind,
type CategoryIconStyleId,
} from "@/lib/category-icon-presets";
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
import { cn } from "@/lib/utils";
export type CategoryIconSelection = {
iconPresetId: string | null;
iconStyle: CategoryIconStyleId | null;
};
type CategoryPresetPickerProps = {
value: CategoryIconSelection;
onChange: (value: CategoryIconSelection) => void;
className?: string;
};
export function CategoryPresetPicker({ value, onChange, className }: CategoryPresetPickerProps) {
const t = useTranslations("menuAdmin");
const activeStyle = value.iconStyle ?? DEFAULT_CATEGORY_ICON_STYLE;
const setStyle = (style: CategoryIconStyleId) => {
onChange({
iconPresetId: value.iconPresetId,
iconStyle: style,
});
};
const setPreset = (presetId: string) => {
onChange({
iconPresetId: presetId,
iconStyle: activeStyle,
});
};
const clearPreset = () => {
onChange({ iconPresetId: null, iconStyle: null });
};
const renderGroup = (kind: CategoryIconPresetKind, label: string) => {
const presets = CATEGORY_ICON_PRESETS.filter((p) => p.kind === kind);
return (
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{label}
</p>
<div className="flex flex-wrap gap-2">
{presets.map((preset) => {
const selected = value.iconPresetId === preset.id;
return (
<button
key={preset.id}
type="button"
title={t(`iconPresets.${preset.id}`)}
onClick={() => setPreset(preset.id)}
className={cn(
"rounded-lg border p-1.5 transition-all active:scale-[0.98]",
selected
? "border-[#0F6E56] bg-[#E1F5EE] ring-1 ring-[#0F6E56]/30"
: "border-border/80 bg-card hover:border-[#0F6E56]/40"
)}
>
<CategoryPresetIcon presetId={preset.id} style={activeStyle} size="md" />
</button>
);
})}
</div>
</div>
);
};
return (
<div className={cn("space-y-3 rounded-lg border border-border/80 bg-muted/20 p-3", className)}>
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("iconStyleLabel")}
</p>
<div className="flex flex-wrap gap-1.5">
{CATEGORY_ICON_STYLES.map((style) => (
<button
key={style}
type="button"
onClick={() => setStyle(style)}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
activeStyle === style
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 bg-card text-muted-foreground hover:border-[#0F6E56]/40"
)}
>
{t(`iconStyles.${style}`)}
</button>
))}
</div>
</div>
{value.iconPresetId ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t("iconPreview")}</span>
<CategoryPresetIcon
presetId={value.iconPresetId}
style={activeStyle}
size="md"
/>
<button
type="button"
className="text-[#0F6E56] underline-offset-2 hover:underline"
onClick={clearPreset}
>
{t("clearIconPreset")}
</button>
</div>
) : null}
{renderGroup("drink", t("iconPresetGroupDrinks"))}
{renderGroup("food", t("iconPresetGroupFood"))}
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import { CategoryPresetIcon } from "@/components/menu/category-preset-icon";
import { resolveMediaUrl } from "@/lib/api/client";
import type { CafeThemePalette } from "@/lib/cafe-theme";
import { cn } from "@/lib/utils";
type CategoryVisualProps = {
icon?: string | null;
iconPresetId?: string | null;
iconStyle?: string | null;
imageUrl?: string | null;
size?: "xs" | "sm" | "md";
className?: string;
brandColors?: Pick<
CafeThemePalette,
"primary" | "secondary" | "accent" | "surface" | "textMuted"
>;
};
const sizeClasses = {
xs: "h-5 w-5 text-sm",
sm: "h-7 w-7 text-base",
md: "h-10 w-10 text-xl",
} as const;
export function CategoryVisual({
icon,
iconPresetId,
iconStyle,
imageUrl,
size = "sm",
className,
brandColors,
}: CategoryVisualProps) {
const imgSrc = resolveMediaUrl(imageUrl);
const emoji = icon?.trim();
if (imgSrc) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imgSrc}
alt=""
className={cn(
"shrink-0 rounded-md border border-border/80 object-cover",
sizeClasses[size],
className
)}
/>
);
}
if (iconPresetId) {
return (
<CategoryPresetIcon
presetId={iconPresetId}
style={iconStyle}
size={size}
className={className}
brandColors={brandColors}
/>
);
}
if (emoji) {
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-md border",
brandColors ? "qr-border" : "border-border/60 bg-muted/40",
sizeClasses[size],
className
)}
style={
brandColors
? {
backgroundColor: `color-mix(in srgb, ${brandColors.secondary} 55%, ${brandColors.surface})`,
borderColor: `color-mix(in srgb, ${brandColors.primary} 18%, transparent)`,
}
: undefined
}
aria-hidden
>
{emoji}
</span>
);
}
return null;
}
@@ -0,0 +1,571 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl";
import { Box, Pencil, Video } from "lucide-react";
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
import { CategoryVisual } from "@/components/menu/category-visual";
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { PageHeader } from "@/components/layout/page-header";
import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { MenuItemMedia } from "@/components/menu/menu-item-media";
import { buildCategoryNameMap, inferMenuItemKind } from "@/lib/menu-item-image";
interface MenuCategory {
id: string;
name: string;
nameEn?: string;
nameAr?: string;
sortOrder: number;
discountPercent: number;
icon?: string;
iconPresetId?: string;
iconStyle?: string;
imageUrl?: string;
isActive: boolean;
}
interface MenuItem {
id: string;
categoryId: string;
name: string;
nameEn?: string;
nameAr?: string;
price: number;
discountPercent: number;
imageUrl?: string;
videoUrl?: string;
model3dUrl?: string;
isAvailable: boolean;
}
function discountedPrice(price: number, percent: number) {
if (percent <= 0) return price;
return Math.round(price * (1 - percent / 100));
}
function mediaField(url: string) {
return url.trim() === "" ? "" : url;
}
export function MenuAdminScreen() {
const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common");
const isRtl = useIsRtl();
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [catName, setCatName] = useState("");
const [catIcon, setCatIcon] = useState("");
const [catIconPreset, setCatIconPreset] = useState<CategoryIconSelection>({
iconPresetId: null,
iconStyle: DEFAULT_CATEGORY_ICON_STYLE,
});
const [catImageUrl, setCatImageUrl] = useState("");
const [editCatName, setEditCatName] = useState("");
const [editCatIcon, setEditCatIcon] = useState("");
const [editCatIconPreset, setEditCatIconPreset] = useState<CategoryIconSelection>({
iconPresetId: null,
iconStyle: DEFAULT_CATEGORY_ICON_STYLE,
});
const [editCatImageUrl, setEditCatImageUrl] = useState("");
const [itemName, setItemName] = useState("");
const [itemNameEn, setItemNameEn] = useState("");
const [itemPrice, setItemPrice] = useState("");
const [itemDiscount, setItemDiscount] = useState("0");
const [itemCategoryId, setItemCategoryId] = useState("");
const [itemImageUrl, setItemImageUrl] = useState("");
const [itemVideoUrl, setItemVideoUrl] = useState("");
const [itemModel3dUrl, setItemModel3dUrl] = useState("");
const [editName, setEditName] = useState("");
const [editNameEn, setEditNameEn] = useState("");
const [editPrice, setEditPrice] = useState("");
const [editDiscount, setEditDiscount] = useState("0");
const [editImageUrl, setEditImageUrl] = useState("");
const [editVideoUrl, setEditVideoUrl] = useState("");
const [editModel3dUrl, setEditModel3dUrl] = useState("");
const { data: categories = [] } = useQuery({
queryKey: ["menu-categories", cafeId],
queryFn: () => apiGet<MenuCategory[]>(`/api/cafes/${cafeId}/menu/categories`),
enabled: !!cafeId,
});
const { data: items = [], isLoading } = useQuery({
queryKey: ["menu-items-all", cafeId],
queryFn: () => apiGet<MenuItem[]>(`/api/cafes/${cafeId}/menu/items`),
enabled: !!cafeId,
});
const categoryNameById = useMemo(
() => buildCategoryNameMap(categories),
[categories]
);
const invalidateMenu = () => {
queryClient.invalidateQueries({ queryKey: ["menu-items-all", cafeId] });
queryClient.invalidateQueries({ queryKey: ["menu-categories", cafeId] });
};
const addCategory = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
name: catName,
sortOrder: categories.length + 1,
discountPercent: 0,
icon: catIcon.trim() || null,
iconPresetId: catIconPreset.iconPresetId,
iconStyle: catIconPreset.iconPresetId ? catIconPreset.iconStyle : null,
imageUrl: catImageUrl.trim() || null,
}),
onSuccess: () => {
setCatName("");
setCatIcon("");
setCatIconPreset({ iconPresetId: null, iconStyle: DEFAULT_CATEGORY_ICON_STYLE });
setCatImageUrl("");
invalidateMenu();
},
});
const updateCategory = useMutation({
mutationFn: (id: string) =>
apiPatch(`/api/cafes/${cafeId}/menu/categories/${id}`, {
name: editCatName,
icon: mediaField(editCatIcon),
iconPresetId: editCatIconPreset.iconPresetId ?? "",
iconStyle: editCatIconPreset.iconPresetId ? editCatIconPreset.iconStyle : "",
imageUrl: mediaField(editCatImageUrl),
}),
onSuccess: () => {
setEditingCategoryId(null);
invalidateMenu();
},
});
const addItem = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/items`, {
categoryId: itemCategoryId,
name: itemName,
nameEn: itemNameEn.trim(),
price: parseFloat(itemPrice),
discountPercent: parseFloat(itemDiscount) || 0,
imageUrl: itemImageUrl || null,
videoUrl: itemVideoUrl || null,
model3dUrl: itemModel3dUrl || null,
}),
onSuccess: () => {
setItemName("");
setItemNameEn("");
setItemPrice("");
setItemDiscount("0");
setItemImageUrl("");
setItemVideoUrl("");
setItemModel3dUrl("");
invalidateMenu();
},
});
const updateItem = useMutation({
mutationFn: (id: string) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}`, {
name: editName,
nameEn: editNameEn.trim(),
price: parseFloat(editPrice),
discountPercent: parseFloat(editDiscount) || 0,
imageUrl: mediaField(editImageUrl),
videoUrl: mediaField(editVideoUrl),
model3dUrl: mediaField(editModel3dUrl),
}),
onSuccess: () => {
setEditingId(null);
invalidateMenu();
},
});
const toggleItem = useMutation({
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
onSuccess: invalidateMenu,
});
const startCategoryEdit = (cat: MenuCategory) => {
setEditingCategoryId(cat.id);
setEditCatName(cat.name);
setEditCatIcon(cat.icon ?? "");
setEditCatIconPreset({
iconPresetId: cat.iconPresetId ?? null,
iconStyle: (cat.iconStyle as CategoryIconSelection["iconStyle"]) ?? DEFAULT_CATEGORY_ICON_STYLE,
});
setEditCatImageUrl(cat.imageUrl ?? "");
};
const startEdit = (item: MenuItem) => {
setEditingId(item.id);
setEditName(item.name);
setEditNameEn(item.nameEn ?? "");
setEditPrice(String(item.price));
setEditDiscount(String(item.discountPercent));
setEditImageUrl(item.imageUrl ?? "");
setEditVideoUrl(item.videoUrl ?? "");
setEditModel3dUrl(item.model3dUrl ?? "");
};
if (!cafeId) return null;
return (
<div className="space-y-6" dir={isRtl ? "rtl" : "ltr"}>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<Card className="rounded-xl border border-border/80 bg-card shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("categories")}
</p>
<CardTitle className="text-base">{t("addCategory")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-2">
<LabeledField label={t("name")} htmlFor="cat-name" className="min-w-[12rem] flex-1">
<Input id="cat-name" value={catName} onChange={(e) => setCatName(e.target.value)} />
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!catName.trim()}
onClick={() => addCategory.mutate()}
>
{t("addCategory")}
</Button>
</div>
<CategoryMediaFields
cafeId={cafeId}
icon={catIcon}
iconPresetId={catIconPreset.iconPresetId}
iconStyle={catIconPreset.iconStyle}
imageUrl={catImageUrl}
onIconChange={setCatIcon}
onPresetChange={setCatIconPreset}
onImageChange={(url) => setCatImageUrl(url ?? "")}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((c) => {
const isEditingCat = editingCategoryId === c.id;
return (
<div
key={c.id}
className={cn(
"flex items-center gap-2 rounded-lg border border-border/80 bg-card px-3 py-2 transition-colors hover:border-[#0F6E56]/40",
isEditingCat && "ring-1 ring-[#0F6E56]/30"
)}
>
<CategoryVisual
icon={c.icon}
iconPresetId={c.iconPresetId}
iconStyle={c.iconStyle}
imageUrl={c.imageUrl}
size="sm"
/>
{isEditingCat ? (
<div className="min-w-0 flex-1 space-y-2">
<Input value={editCatName} onChange={(e) => setEditCatName(e.target.value)} />
<CategoryMediaFields
cafeId={cafeId}
icon={editCatIcon}
iconPresetId={editCatIconPreset.iconPresetId}
iconStyle={editCatIconPreset.iconStyle}
imageUrl={editCatImageUrl}
onIconChange={setEditCatIcon}
onPresetChange={setEditCatIconPreset}
onImageChange={(url) => setEditCatImageUrl(url ?? "")}
/>
<div className="flex gap-2">
<Button
size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!editCatName.trim()}
onClick={() => updateCategory.mutate(c.id)}
>
{tCommon("save")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingCategoryId(null)}>
{tCommon("cancel")}
</Button>
</div>
</div>
) : (
<>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{c.name}</span>
<Button size="sm" variant="ghost" onClick={() => startCategoryEdit(c)}>
<Pencil className="h-3 w-3" />
</Button>
</>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
<section>
<p className="mb-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("items")}
</p>
<Card className="mb-4 rounded-xl border border-border/80 bg-card shadow-sm">
<CardContent className="space-y-3 pt-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<LabeledField label={t("category")} htmlFor="item-category">
<select
id="item-category"
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
value={itemCategoryId}
onChange={(e) => setItemCategoryId(e.target.value)}
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("name")} htmlFor="item-name">
<Input id="item-name" value={itemName} onChange={(e) => setItemName(e.target.value)} />
</LabeledField>
<LabeledField label={t("nameEn")} htmlFor="item-name-en">
<Input
id="item-name-en"
value={itemNameEn}
onChange={(e) => setItemNameEn(e.target.value)}
dir="ltr"
className="text-start"
/>
</LabeledField>
<LabeledField label={t("price")} htmlFor="item-price">
<Input
id="item-price"
value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)}
inputMode="numeric"
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("discountPercent")} htmlFor="item-discount">
<Input
id="item-discount"
value={itemDiscount}
onChange={(e) => setItemDiscount(e.target.value)}
inputMode="numeric"
dir="ltr"
className="text-end"
/>
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46] self-end"
disabled={!itemName.trim() || !itemNameEn.trim() || !itemCategoryId || !itemPrice}
onClick={() => addItem.mutate()}
>
{t("addItem")}
</Button>
</div>
<LabeledField label={t("media")}>
<MediaPairUpload
cafeId={cafeId}
kind="menu"
imageUrl={itemImageUrl}
videoUrl={itemVideoUrl}
onImageChange={(url) => setItemImageUrl(url ?? "")}
onVideoChange={(url) => setItemVideoUrl(url ?? "")}
/>
<Menu3dUpload
cafeId={cafeId}
model3dUrl={itemModel3dUrl || null}
onChange={(url) => setItemModel3dUrl(url ?? "")}
/>
</LabeledField>
</CardContent>
</Card>
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => {
const kind = inferMenuItemKind(
item.categoryId,
categoryNameById.get(item.categoryId)
);
const hasDiscount = item.discountPercent > 0;
const salePrice = discountedPrice(item.price, item.discountPercent);
const isEditing = editingId === item.id;
return (
<Card
key={item.id}
className={cn(
"relative overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm transition-colors hover:border-[#0F6E56]/40",
!item.isAvailable && "opacity-60"
)}
>
{hasDiscount ? (
<span
className={cn(
"absolute top-2 z-10 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-[#BA7517]",
isRtl ? "start-2" : "end-2"
)}
>
{formatNumber(item.discountPercent)}٪ {t("discountBadge")}
</span>
) : null}
<div className="relative aspect-[4/3] overflow-hidden bg-muted/50">
<MenuItemMedia
imageUrl={item.imageUrl}
kind={kind}
size="md"
className="absolute inset-0"
/>
{item.videoUrl ? (
<span className="absolute bottom-2 start-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white">
<Video className="h-3 w-3" />
Video
</span>
) : null}
{item.model3dUrl ? (
<span className="absolute bottom-2 end-2 flex items-center gap-1 rounded-md bg-[#0F6E56]/90 px-2 py-0.5 text-[10px] text-white">
<Box className="h-3 w-3" />
3D
</span>
) : null}
</div>
<CardContent className="space-y-2 p-4">
{isEditing ? (
<div className="space-y-2">
<LabeledField label={t("name")} htmlFor={`edit-name-${item.id}`}>
<Input
id={`edit-name-${item.id}`}
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("nameEn")} htmlFor={`edit-name-en-${item.id}`}>
<Input
id={`edit-name-en-${item.id}`}
value={editNameEn}
onChange={(e) => setEditNameEn(e.target.value)}
dir="ltr"
className="text-start"
/>
</LabeledField>
<LabeledField label={t("price")} htmlFor={`edit-price-${item.id}`}>
<Input
id={`edit-price-${item.id}`}
value={editPrice}
onChange={(e) => setEditPrice(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("discountPercent")} htmlFor={`edit-discount-${item.id}`}>
<Input
id={`edit-discount-${item.id}`}
value={editDiscount}
onChange={(e) => setEditDiscount(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<MediaPairUpload
cafeId={cafeId}
kind="menu"
imageUrl={editImageUrl}
videoUrl={editVideoUrl}
onImageChange={(url) => setEditImageUrl(url ?? "")}
onVideoChange={(url) => setEditVideoUrl(url ?? "")}
/>
<Menu3dUpload
cafeId={cafeId}
model3dUrl={editModel3dUrl || null}
onChange={(url) => setEditModel3dUrl(url ?? "")}
/>
<MenuAi3dGenerate
cafeId={cafeId}
itemId={item.id}
imageUrl={editImageUrl || item.imageUrl}
onGenerated={(url) => setEditModel3dUrl(url)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => updateItem.mutate(item.id)}>
{tCommon("save")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
{tCommon("cancel")}
</Button>
</div>
</div>
) : (
<>
<MenuItemLabels item={item} primaryClassName="text-sm" />
<div className="flex items-baseline gap-2">
{hasDiscount ? (
<>
<span className="text-xs text-muted-foreground line-through">
{formatCurrency(item.price)}
</span>
<span className="text-sm font-medium text-[#0F6E56]">
{formatCurrency(salePrice)}
</span>
</>
) : (
<span className="text-sm font-medium text-[#0F6E56]">
{formatCurrency(item.price)}
</span>
)}
</div>
<div className="flex gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => startEdit(item)}>
<Pencil className="me-1 h-3 w-3" />
{t("editItem")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => toggleItem.mutate({ id: item.id, isAvailable: !item.isAvailable })}
>
{item.isAvailable ? t("available") : t("unavailable")}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useLocale } from "next-intl";
import { cn } from "@/lib/utils";
import {
getMenuEnglishSubtitle,
getMenuPrimaryName,
type MenuNameFields,
} from "@/lib/menu-display";
type MenuItemLabelsProps = {
item: MenuNameFields;
className?: string;
primaryClassName?: string;
secondaryClassName?: string;
lines?: 1 | 2;
};
export function MenuItemLabels({
item,
className,
primaryClassName,
secondaryClassName,
lines = 2,
}: MenuItemLabelsProps) {
const locale = useLocale();
const primary = getMenuPrimaryName(item, locale);
const english = getMenuEnglishSubtitle(item, locale);
return (
<div className={cn("min-w-0", className)}>
<p
className={cn(
"font-medium leading-snug",
lines === 2 ? "line-clamp-2" : "truncate",
primaryClassName
)}
>
{primary}
</p>
{english ? (
<p
className={cn(
"mt-0.5 truncate text-[11px] text-muted-foreground",
secondaryClassName
)}
dir="ltr"
>
{english}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import type { MenuItemVisualKind } from "@/lib/menu-item-image";
import {
getMenuItemImageSrc,
menuItemPlaceholderHeroIcon,
menuItemPlaceholderIcon,
} from "@/lib/menu-item-image";
import { cn } from "@/lib/utils";
type MenuItemMediaProps = {
imageUrl?: string | null;
kind: MenuItemVisualKind;
size?: "xs" | "sm" | "md" | "hero";
className?: string;
imgClassName?: string;
};
const iconSize: Record<NonNullable<MenuItemMediaProps["size"]>, string> = {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-10 w-10",
hero: "h-8 w-8",
};
const placeholderBg: Record<MenuItemVisualKind, string> = {
drink: "bg-[#E8F4F8]",
food: "bg-[#F5F0EB]",
};
const placeholderIconColor: Record<MenuItemVisualKind, string> = {
drink: "text-[#0F6E56]/45",
food: "text-[#8B6914]/40",
};
export function MenuItemMedia({
imageUrl,
kind,
size = "sm",
className,
imgClassName,
}: MenuItemMediaProps) {
const src = getMenuItemImageSrc(imageUrl);
const [loadFailed, setLoadFailed] = useState(false);
const PlaceholderIcon =
size === "hero" ? menuItemPlaceholderHeroIcon(kind) : menuItemPlaceholderIcon(kind);
const showPlaceholder = !src || loadFailed;
if (showPlaceholder) {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center",
placeholderBg[kind],
className
)}
aria-hidden
>
<PlaceholderIcon className={cn(iconSize[size], placeholderIconColor[kind])} />
</div>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
className={cn("h-full w-full object-cover", imgClassName, className)}
loading="lazy"
onError={() => setLoadFailed(true)}
/>
);
}
@@ -0,0 +1,41 @@
"use client";
import "@google/model-viewer";
import { resolveMediaUrl } from "@/lib/api/client";
type MenuItemModelViewerProps = {
modelUrl: string;
posterUrl?: string | null;
alt: string;
className?: string;
};
export function MenuItemModelViewer({
modelUrl,
posterUrl,
alt,
className,
}: MenuItemModelViewerProps) {
const src = resolveMediaUrl(modelUrl);
const poster = posterUrl ? resolveMediaUrl(posterUrl) : undefined;
if (!src) return null;
return (
// @ts-expect-error model-viewer is a custom element from @google/model-viewer
<model-viewer
src={src}
poster={poster}
alt={alt}
camera-controls
touch-action="pan-y"
auto-rotate
rotation-per-second="28deg"
interaction-prompt="none"
shadow-intensity="1"
exposure="1"
environment-image="neutral"
className={className}
style={{ width: "100%", height: "100%", minHeight: "min(72vh, 420px)" }}
/>
);
}
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { Bell } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
import type { CafeNotification } from "@/lib/api/notifications";
import { numberLocaleForUi } from "@/lib/format-datetime";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDetailPanel, NotificationRow } from "@/components/notifications/notification-ui";
import { cn } from "@/lib/utils";
export function NotificationCenter() {
const t = useTranslations("notifications");
const locale = useLocale();
const numberLocale = numberLocaleForUi(locale);
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<CafeNotification | null>(null);
const { items, unreadCount, openNotification, markAllRead } = useNotificationsFeed({
enableToasts: true,
limit: 20,
});
const handleSelect = async (n: CafeNotification) => {
await openNotification(n);
setSelected({ ...n, isRead: true });
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setSelected(null);
};
const isRtl = locale !== "en";
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative shrink-0"
aria-label={t("title")}
>
<Bell className="size-4" />
{unreadCount > 0 ? (
<span className="absolute -top-1 -end-1 flex size-5 items-center justify-center rounded-full bg-[#0F6E56] text-[10px] font-bold text-white">
{unreadCount > 9
? locale === "fa"
? "۹+"
: "9+"
: unreadCount.toLocaleString(numberLocale)}
</span>
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={isRtl ? "start" : "end"}
sideOffset={4}
collisionPadding={12}
className={cn(
"w-80 max-h-[min(24rem,70vh)] overflow-y-auto overflow-x-hidden p-0",
"rounded-xl border border-border/80 shadow-lg"
)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-border/80 bg-card px-3 py-2">
<span className="text-sm font-semibold">{t("title")}</span>
{unreadCount > 0 && !selected ? (
<button
type="button"
className="text-xs font-medium text-[#0F6E56] hover:underline"
onClick={() => void markAllRead()}
>
{t("markAllRead")}
</button>
) : null}
</div>
{selected ? (
<NotificationDetailPanel
item={selected}
locale={locale}
onBack={() => setSelected(null)}
/>
) : items.length === 0 ? (
<p className="px-3 py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="flex flex-col gap-2 p-2">
{items.map((n) => (
<li key={n.id}>
<NotificationRow
item={n}
locale={locale}
compact
onSelect={(item) => void handleSelect(item)}
/>
</li>
))}
</ul>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useTranslations } from "next-intl";
import { format } from "date-fns-jalali";
import { enUS } from "date-fns-jalali/locale/en-US";
import { faIR } from "date-fns-jalali/locale/fa-IR";
import { Bell, ChefHat, UtensilsCrossed, type LucideIcon } from "lucide-react";
import type { CafeNotification } from "@/lib/api/notifications";
import { cn } from "@/lib/utils";
export function notificationIcon(type: string): LucideIcon {
if (type === "table_call_waiter") return Bell;
if (type.startsWith("guest_order")) return ChefHat;
if (type.includes("table")) return UtensilsCrossed;
return Bell;
}
export function formatNotificationTime(iso: string, locale: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const jalaliLocale = locale === "en" ? enUS : faIR;
return format(date, "d MMM yyyy — HH:mm", { locale: jalaliLocale });
}
type NotificationRowProps = {
item: CafeNotification;
locale: string;
onSelect: (n: CafeNotification) => void;
compact?: boolean;
};
export function NotificationRow({ item, locale, onSelect, compact }: NotificationRowProps) {
const Icon = notificationIcon(item.type);
const timeLabel = formatNotificationTime(item.createdAt, locale);
return (
<button
type="button"
onClick={() => onSelect(item)}
className={cn(
"flex w-full gap-3 rounded-xl border border-border bg-card text-start shadow-sm transition active:scale-[0.98]",
"hover:border-primary/40",
!item.isRead && "border-primary/25 bg-[#E1F5EE]/40",
compact ? "p-3" : "p-4"
)}
>
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-lg",
compact ? "size-9" : "size-10",
item.isRead ? "bg-muted text-muted-foreground" : "bg-[#E1F5EE] text-[#0F6E56]"
)}
>
<Icon className={compact ? "size-4" : "size-5"} aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-start justify-between gap-2">
<span className={cn("font-medium text-foreground", compact ? "text-xs" : "text-sm")}>
{item.title}
</span>
{!item.isRead ? (
<span className="mt-1 size-2 shrink-0 rounded-full bg-[#0F6E56]" aria-hidden />
) : null}
</span>
{item.body && !compact ? (
<span className="mt-1 line-clamp-2 block text-sm leading-relaxed text-muted-foreground">
{item.body}
</span>
) : null}
<span className="mt-1.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
{timeLabel ? <span>{timeLabel}</span> : null}
{item.tableNumber ? (
<span className="rounded-full border border-border/80 px-2 py-0.5">
{item.tableNumber}
</span>
) : null}
</span>
</span>
</button>
);
}
type NotificationDetailPanelProps = {
item: CafeNotification;
locale: string;
onBack: () => void;
};
export function NotificationDetailPanel({ item, locale, onBack }: NotificationDetailPanelProps) {
const t = useTranslations("notifications");
const Icon = notificationIcon(item.type);
const timeLabel = formatNotificationTime(item.createdAt, locale);
return (
<div className="flex flex-col gap-3 p-3">
<button
type="button"
onClick={onBack}
className="self-start text-xs font-medium text-[#0F6E56] hover:underline"
>
{t("backToList")}
</button>
<div className="flex gap-3 rounded-xl border border-primary/20 bg-[#E1F5EE]/50 p-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#E1F5EE] text-[#0F6E56]">
<Icon className="size-5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{item.title}</p>
{item.body ? (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.body}</p>
) : null}
<div className="mt-3 flex flex-wrap gap-2 text-[11px] text-muted-foreground">
{timeLabel ? <span>{timeLabel}</span> : null}
{item.tableNumber ? (
<span className="rounded-full border border-border/80 bg-card px-2 py-0.5">
{item.tableNumber}
</span>
) : null}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Bell } from "lucide-react";
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
import { NotificationRow } from "@/components/notifications/notification-ui";
import { numberLocaleForUi } from "@/lib/format-datetime";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type FilterMode = "all" | "unread";
export function NotificationsScreen() {
const t = useTranslations("notifications");
const locale = useLocale();
const [filter, setFilter] = useState<FilterMode>("all");
const unreadOnly = filter === "unread";
const { cafeId, items, unreadCount, isLoading, isFetching, openNotification, markAllRead } =
useNotificationsFeed({ unreadOnly });
if (!cafeId) return null;
const numberLocale = numberLocaleForUi(locale);
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-medium text-foreground">{t("pageTitle")}</h1>
{unreadCount > 0 ? (
<p className="mt-0.5 text-xs text-muted-foreground">
{t("unreadCount", { count: unreadCount.toLocaleString(numberLocale) })}
</p>
) : null}
</div>
{unreadCount > 0 ? (
<Button variant="outline" size="sm" onClick={() => void markAllRead()}>
{t("markAllRead")}
</Button>
) : null}
</div>
<div
className="inline-flex rounded-lg border border-border bg-card p-1"
role="tablist"
aria-label={t("filterLabel")}
>
<button
type="button"
role="tab"
aria-selected={filter === "all"}
className={cn(
"rounded-md px-3 py-1.5 text-sm transition",
filter === "all"
? "bg-[#E1F5EE] font-medium text-[#0F6E56]"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setFilter("all")}
>
{t("filterAll")}
</button>
<button
type="button"
role="tab"
aria-selected={filter === "unread"}
className={cn(
"rounded-md px-3 py-1.5 text-sm transition",
filter === "unread"
? "bg-[#E1F5EE] font-medium text-[#0F6E56]"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setFilter("unread")}
>
{t("filterUnread")}
</button>
</div>
{isLoading ? (
<p className="py-12 text-center text-sm text-muted-foreground">{t("loading")}</p>
) : items.length === 0 ? (
<div className="rounded-xl border border-border bg-card px-6 py-16 text-center">
<Bell className="mx-auto mb-3 size-10 text-muted-foreground/50" aria-hidden />
<p className="text-sm text-muted-foreground">
{filter === "unread" ? t("emptyUnread") : t("empty")}
</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{items.map((n) => (
<li key={n.id}>
<NotificationRow
item={n}
locale={locale}
onSelect={(item) => void openNotification(item, { navigate: true })}
/>
</li>
))}
</ul>
)}
{isFetching && !isLoading ? (
<p className="text-center text-[11px] text-muted-foreground">{t("refreshing")}</p>
) : null}
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatNumber } from "@/lib/format";
interface ChartPoint {
label: string;
revenue: number;
}
export function OverviewMiniChart({
data,
numberLocale,
}: {
data: ChartPoint[];
numberLocale: string;
}) {
if (data.length === 0) return null;
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="ovRevFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.28} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" vertical={false} />
<XAxis
dataKey="label"
tick={{ fill: "#475569", fontSize: 10 }}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: "#475569", fontSize: 10 }}
axisLine={false}
tickLine={false}
width={52}
tickFormatter={(v: number) => formatNumber(v, numberLocale)}
/>
<Tooltip
contentStyle={{
background: "rgba(2,6,23,0.96)",
border: "1px solid rgba(51,65,85,0.7)",
borderRadius: "10px",
color: "#e2e8f0",
fontSize: 12,
}}
itemStyle={{ color: "#10b981" }}
formatter={(v: number) => [formatNumber(v, numberLocale), ""]}
labelStyle={{ color: "#94a3b8", marginBottom: 2 }}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#10b981"
strokeWidth={2}
fill="url(#ovRevFill)"
dot={false}
activeDot={{ r: 4, fill: "#10b981", stroke: "#020617", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,408 @@
"use client";
import { lazy, Suspense, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import {
TrendingUp,
TrendingDown,
Minus,
ShoppingBag,
CreditCard,
Utensils,
BarChart3,
ArrowRight,
ArrowLeft,
ChefHat,
TableProperties,
} from "lucide-react";
import { Link } from "@/i18n/routing";
import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useLiveClock } from "@/lib/hooks/use-live-clock";
import {
addDaysIso,
isoTodayTehran,
percentChange,
revenueChartPoints,
sumSnapshots,
topProductsFromRange,
type DailyReportSnapshot,
} from "@/lib/reports/analytics";
import { fetchCafeTableBoard } from "@/lib/api/branch-tables";
import { formatCurrency, formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { Card } from "@/components/ui/card";
const LazyMiniChart = lazy(() =>
import("@/components/overview/overview-mini-chart").then((m) => ({
default: m.OverviewMiniChart,
}))
);
function KpiValue({
value,
loading,
currency = false,
locale = "fa-IR",
}: {
value: number | null;
loading: boolean;
currency?: boolean;
locale?: string;
}) {
if (loading) return <Skeleton className="h-7 w-28 mt-1" />;
if (value === null) return <span className="text-2xl font-bold text-muted-foreground"></span>;
return (
<span className="text-2xl font-bold text-foreground tabular-nums">
{currency ? formatCurrency(value, locale) : formatNumber(value, locale)}
</span>
);
}
function TrendBadge({ delta, label }: { delta: number | null; label: string }) {
if (delta === null) return null;
const up = delta > 0;
const flat = delta === 0;
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
up
? "bg-green-100 text-green-700"
: flat
? "bg-muted text-muted-foreground"
: "bg-red-100 text-red-600"
)}
>
{up ? (
<TrendingUp className="h-3 w-3" />
) : flat ? (
<Minus className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{Math.abs(delta).toFixed(1)}%
<span className="text-muted-foreground">{label}</span>
</span>
);
}
export function OverviewScreen() {
const t = useTranslations("overview");
const tNav = useTranslations("nav");
const locale = useLocale();
const rtl = locale !== "en";
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const ArrowIcon = rtl ? ArrowLeft : ArrowRight;
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useAuthStore((s) => s.user?.branchId ?? null);
const role = useAuthStore((s) => s.user?.role);
const clock = useLiveClock(10_000);
const today = useMemo(() => isoTodayTehran(), []);
const sevenDaysAgo = useMemo(() => addDaysIso(today, -6), [today]);
const yesterday = useMemo(() => addDaysIso(today, -1), [today]);
const timeStr = clock.toLocaleTimeString(
locale === "fa" ? "fa-IR" : locale === "ar" ? "ar-SA" : "en-US",
{ hour: "2-digit", minute: "2-digit", timeZone: "Asia/Tehran" }
);
const dateStr = clock.toLocaleDateString(
locale === "fa" ? "fa-IR-u-ca-persian" : locale === "ar" ? "ar-SA" : "en-GB",
{ weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "Asia/Tehran" }
);
const { data: weekSnapshots = [], isLoading } = useQuery({
queryKey: ["overview-week", cafeId, today],
queryFn: () =>
apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${sevenDaysAgo}&to=${today}`
),
enabled: !!cafeId,
staleTime: 60_000,
});
const { data: prevSnapshots = [] } = useQuery({
queryKey: ["overview-yesterday", cafeId, yesterday],
queryFn: () =>
apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${yesterday}&to=${yesterday}`
),
enabled: !!cafeId,
staleTime: 300_000,
});
const { data: tables = [] } = useQuery({
queryKey: ["overview-tables", cafeId, branchId],
queryFn: () => fetchCafeTableBoard(cafeId!, branchId),
enabled: !!cafeId,
staleTime: 30_000,
refetchInterval: 30_000,
});
const todaySnapshot = useMemo(
() => weekSnapshots.find((s) => s.date === today) ?? null,
[weekSnapshots, today]
);
const todayTotals = useMemo(
() => (todaySnapshot ? sumSnapshots([todaySnapshot]) : null),
[todaySnapshot]
);
const yesterdayTotals = useMemo(
() => (prevSnapshots.length > 0 ? sumSnapshots(prevSnapshots) : null),
[prevSnapshots]
);
const revenueDelta = useMemo(
() => todayTotals && yesterdayTotals
? percentChange(todayTotals.totalRevenue, yesterdayTotals.totalRevenue)
: null,
[todayTotals, yesterdayTotals]
);
const ordersDelta = useMemo(
() => todayTotals && yesterdayTotals
? percentChange(todayTotals.totalOrders, yesterdayTotals.totalOrders)
: null,
[todayTotals, yesterdayTotals]
);
const netIncomeDelta = useMemo(
() => todayTotals && yesterdayTotals
? percentChange(todayTotals.netIncome, yesterdayTotals.netIncome)
: null,
[todayTotals, yesterdayTotals]
);
const chartPoints = useMemo(
() => revenueChartPoints(weekSnapshots, locale, rtl),
[weekSnapshots, locale, rtl]
);
const topProducts = useMemo(() => topProductsFromRange(weekSnapshots, 5), [weekSnapshots]);
const tableStats = useMemo(() => {
const active = tables.filter((tb) => tb.isActive);
return {
free: active.filter((tb) => tb.status === "Free").length,
busy: active.filter((tb) => tb.status === "Busy").length,
cleaning: active.filter((tb) => tb.status === "Cleaning").length,
total: active.length,
};
}, [tables]);
const quickLinks = [
{ key: "pos", href: "/pos", icon: CreditCard, labelKey: "pos" },
{ key: "tables", href: "/tables", icon: TableProperties, labelKey: "tables" },
{ key: "reports", href: "/reports", icon: BarChart3, labelKey: "reports" },
{ key: "kds", href: "/kds", icon: ChefHat, labelKey: "kds" },
] as const;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-auto">
{/* Welcome + Clock */}
<Card className="col-span-2 p-5 flex flex-col bg-primary/5 border-primary/20">
<p className="text-xs font-semibold uppercase tracking-widest text-primary/70 mb-1">
{t("greeting")}
{role && <span className="ms-1.5 text-muted-foreground normal-case tracking-normal font-normal">· {role}</span>}
</p>
<p className="text-3xl font-bold text-foreground tabular-nums leading-none">{timeStr}</p>
<p className="mt-1.5 text-xs text-muted-foreground truncate">{dateStr}</p>
<div className="mt-auto pt-4">
<span className="text-[11px] text-muted-foreground/50">Meezi</span>
</div>
</Card>
{/* Revenue KPI */}
<Card className="col-span-1 p-5 flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("todayRevenue")}
</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
<CreditCard className="h-3.5 w-3.5 text-primary" />
</div>
</div>
<KpiValue value={todayTotals?.totalRevenue ?? null} loading={isLoading} currency locale={numberLocale} />
<div className="mt-2">
<TrendBadge delta={revenueDelta} label={t("vsYesterday")} />
</div>
</Card>
{/* Orders KPI */}
<Card className="col-span-1 p-5 flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("todayOrders")}
</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-blue-100">
<ShoppingBag className="h-3.5 w-3.5 text-blue-600" />
</div>
</div>
<KpiValue value={todayTotals?.totalOrders ?? null} loading={isLoading} locale={numberLocale} />
<div className="mt-2">
<TrendBadge delta={ordersDelta} label={t("vsYesterday")} />
</div>
</Card>
{/* 7-day Chart (3×2) */}
<Card className="col-span-2 lg:col-span-3 lg:row-span-2 p-5 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground">{t("revenueChart")}</h3>
<Link
href="/reports"
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-primary transition-colors"
>
{tNav("reports")}
<ArrowIcon className="h-3 w-3" />
</Link>
</div>
<div className="flex-1 min-h-[180px] lg:min-h-[220px]">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : chartPoints.length === 0 ? (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">{t("noData")}</p>
</div>
) : (
<Suspense fallback={<Skeleton className="h-full w-full" />}>
<LazyMiniChart data={chartPoints} numberLocale={numberLocale} />
</Suspense>
)}
</div>
</Card>
{/* Table Status (1×2) */}
<Card className="col-span-2 lg:col-span-1 lg:row-span-2 p-5 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-foreground">{t("tableStatus")}</h3>
<Link href="/tables" className="text-muted-foreground hover:text-primary transition-colors">
<TableProperties className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex flex-col items-center justify-center flex-1 gap-4">
{/* Occupancy ring */}
<div className="relative flex h-24 w-24 items-center justify-center">
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="15.5" fill="none" stroke="hsl(var(--muted))" strokeWidth="3" />
{tableStats.total > 0 && (
<circle
cx="18" cy="18" r="15.5" fill="none"
stroke="hsl(var(--primary))" strokeWidth="3" strokeLinecap="round"
strokeDasharray={`${(tableStats.busy / tableStats.total) * 97.4} 97.4`}
/>
)}
</svg>
<div className="text-center">
<p className="text-2xl font-bold text-foreground tabular-nums leading-none">{tableStats.busy}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">/ {tableStats.total}</p>
</div>
</div>
<div className="w-full space-y-2.5">
{[
{ label: t("tableFree"), count: tableStats.free, cls: "bg-green-500", txt: "text-green-600" },
{ label: t("tableBusy"), count: tableStats.busy, cls: "bg-amber-500", txt: "text-amber-600" },
{ label: t("tableCleaning"), count: tableStats.cleaning, cls: "bg-blue-500", txt: "text-blue-600" },
].map(({ label, count, cls, txt }) => (
<div key={label} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("h-2 w-2 rounded-full", cls)} />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
<span className={cn("text-sm font-semibold tabular-nums", txt)}>
{formatNumber(count, numberLocale)}
</span>
</div>
))}
</div>
</div>
</Card>
{/* Top Products (2 cols) */}
<Card className="col-span-2 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">{t("topProducts")}</h3>
<Utensils className="h-4 w-4 text-muted-foreground" />
</div>
{isLoading ? (
<div className="space-y-2.5">{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-5 w-full" />)}</div>
) : topProducts.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noData")}</p>
) : (
<div className="space-y-2">
{topProducts.map((p, idx) => {
const maxRevenue = topProducts[0]?.revenue ?? 1;
const pct = Math.round((p.revenue / maxRevenue) * 100);
return (
<div key={p.productId} className="flex items-center gap-3">
<span className="w-4 shrink-0 text-[11px] font-medium text-muted-foreground tabular-nums text-end">
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-foreground truncate">{p.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0 ms-2">
{formatNumber(p.quantity, numberLocale)} {t("unit")}
</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary/60 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{formatCurrency(p.revenue, numberLocale)}
</span>
</div>
);
})}
</div>
)}
</Card>
{/* Net Income KPI */}
<Card className="col-span-1 p-5 flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("netIncome")}
</span>
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-purple-100">
<BarChart3 className="h-3.5 w-3.5 text-purple-600" />
</div>
</div>
<KpiValue value={todayTotals?.netIncome ?? null} loading={isLoading} currency locale={numberLocale} />
<div className="mt-2">
<TrendBadge delta={netIncomeDelta} label={t("vsYesterday")} />
</div>
</Card>
{/* Quick Links */}
<Card className="col-span-1 p-5 flex flex-col">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
{t("quickLinks")}
</p>
<div className="grid grid-cols-2 gap-2 flex-1">
{quickLinks.map(({ key, href, icon: Icon, labelKey }) => (
<Link
key={key}
href={href}
className="flex flex-col items-center justify-center gap-1.5 rounded-xl bg-muted/50 border border-border p-2.5 text-center transition-colors hover:bg-accent hover:text-accent-foreground group"
>
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
<span className="text-[10px] text-muted-foreground group-hover:text-primary transition-colors leading-tight">
{tNav(labelKey)}
</span>
</Link>
))}
</div>
</Card>
</div>
);
}
@@ -0,0 +1,15 @@
"use client";
import { useTranslations } from "next-intl";
export function PlaceholderPage({ titleKey }: { titleKey: string }) {
const tNav = useTranslations("nav");
const tCommon = useTranslations("common");
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-muted-foreground">
{tNav(titleKey as "crm")} {tCommon("comingSoon")}
</p>
</div>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { ChevronDown, Search, UserPlus, X } from "lucide-react";
import { apiGet, apiPost, ApiClientError } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { cn } from "@/lib/utils";
type CustomerMode = "existing" | "new";
type PosCustomerPickerProps = {
cafeId: string;
guestName: string;
guestPhone: string;
customerId: string | null;
onGuestNameChange: (value: string) => void;
onGuestPhoneChange: (value: string) => void;
onCustomerChange: (customer: Customer | null) => void;
onClearCustomer: () => void;
/** Collapsed header in POS sidebar to leave room for cart lines */
compact?: boolean;
};
export function PosCustomerPicker({
cafeId,
guestName,
guestPhone,
customerId,
onGuestNameChange,
onGuestPhoneChange,
onCustomerChange,
onClearCustomer,
compact = false,
}: PosCustomerPickerProps) {
const t = useTranslations("pos");
const tCrm = useTranslations("crm");
const tCommon = useTranslations("common");
const [expanded, setExpanded] = useState(!compact);
const [mode, setMode] = useState<CustomerMode>(customerId ? "existing" : "new");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [highlightIndex, setHighlightIndex] = useState(-1);
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const pickCustomer = (c: Customer) => {
onCustomerChange(c);
setSearch("");
setMessage(null);
setHighlightIndex(-1);
};
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIndex((i) => (i < results.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((i) => (i > 0 ? i - 1 : results.length - 1));
} else if (e.key === "Enter" && highlightIndex >= 0) {
e.preventDefault();
pickCustomer(results[highlightIndex]!);
}
};
useEffect(() => {
if (customerId) setMode("existing");
}, [customerId]);
const { data: results = [], isFetching } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch],
queryFn: () =>
apiGet<Customer[]>(
`/api/cafes/${cafeId}/customers?q=${encodeURIComponent(debouncedSearch)}`
),
enabled: !!cafeId && mode === "existing" && debouncedSearch.length >= 2,
});
useEffect(() => {
setHighlightIndex(-1);
}, [debouncedSearch, results.length]);
const createCustomer = useMutation({
mutationFn: () =>
apiPost<Customer>(`/api/cafes/${cafeId}/customers`, {
name: guestName.trim(),
phone: guestPhone.trim(),
group: "New",
}),
onSuccess: (customer) => {
onCustomerChange(customer);
setMode("existing");
setMessage(t("customerSaved"));
},
onError: (err: Error) => {
if (err instanceof ApiClientError && err.code === "DUPLICATE_PHONE") {
setMessage(t("customerPhoneExists"));
return;
}
setMessage(t("customerSaveError"));
},
});
const switchMode = (next: CustomerMode) => {
setMode(next);
setMessage(null);
if (next === "new") {
onClearCustomer();
}
};
const canSaveNew =
guestName.trim().length > 0 &&
/^09\d{9}$/.test(guestPhone.trim()) &&
!customerId;
const summaryLabel =
guestName.trim() || guestPhone.trim()
? `${guestName.trim() || "—"}${guestPhone.trim() ? ` · ${guestPhone.trim()}` : ""}`
: t("customerSection");
return (
<div className={cn("space-y-2", compact && !expanded && "space-y-1")}>
{compact ? (
<button
type="button"
className="flex w-full items-center justify-between gap-2 rounded-md border border-border/80 bg-muted/30 px-2 py-1.5 text-start"
onClick={() => setExpanded((v) => !v)}
>
<span className="min-w-0 truncate text-xs font-medium">{summaryLabel}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-180"
)}
/>
</button>
) : (
<p className="text-xs font-medium text-muted-foreground">{t("customerSection")}</p>
)}
{compact && !expanded ? null : (
<>
<div className="flex gap-1 rounded-lg border border-border p-0.5">
<button
type="button"
className={cn(
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
mode === "existing"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted"
)}
onClick={() => switchMode("existing")}
>
{t("existingCustomer")}
</button>
<button
type="button"
className={cn(
"flex-1 rounded-md px-2 py-1.5 text-xs font-medium transition",
mode === "new"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted"
)}
onClick={() => switchMode("new")}
>
{t("newCustomer")}
</button>
</div>
{customerId ? (
<div className="flex items-center justify-between gap-2 rounded-md border border-[#0F6E56]/30 bg-[#E1F5EE] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-[#0F6E56]">{guestName}</p>
<p className="truncate text-xs text-muted-foreground" dir="ltr">
{guestPhone}
</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => {
onClearCustomer();
onGuestNameChange("");
onGuestPhoneChange("");
setMode("new");
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
{mode === "existing" && !customerId ? (
<div className="space-y-2">
<LabeledField label={tCommon("search")} htmlFor="pos-customer-search">
<div className="relative">
<Search className="absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="pos-customer-search"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder={t("customerSearchPlaceholder")}
className={cn("ps-8", compact && "h-8 text-sm")}
role="combobox"
aria-expanded={results.length > 0 && debouncedSearch.length >= 2}
aria-activedescendant={
highlightIndex >= 0 ? `pos-customer-opt-${highlightIndex}` : undefined
}
/>
</div>
</LabeledField>
{debouncedSearch.length < 2 ? (
<p className="text-[11px] text-muted-foreground">{t("customerSearchHint")}</p>
) : isFetching ? (
<p className="text-[11px] text-muted-foreground">{tCommon("loading")}</p>
) : results.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{t("customerNotFound")}</p>
) : (
<ul
className={cn(
"space-y-1 overflow-y-auto overscroll-contain",
compact ? "max-h-24" : "max-h-32"
)}
>
{results.map((c, idx) => (
<li key={c.id} id={`pos-customer-opt-${idx}`}>
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md border px-2 py-1.5 text-start text-sm transition",
idx === highlightIndex
? "border-primary bg-primary/10"
: "border-border/80 hover:border-primary hover:bg-muted/40"
)}
onClick={() => pickCustomer(c)}
>
<span className="min-w-0 truncate font-medium">{c.name}</span>
<span className="shrink-0 text-xs text-muted-foreground" dir="ltr">
{c.phone}
</span>
</button>
</li>
))}
</ul>
)}
</div>
) : null}
{mode === "new" && !customerId ? (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (canSaveNew && !createCustomer.isPending) createCustomer.mutate();
}}
>
<LabeledField label={t("guestName")} htmlFor="pos-guest">
<Input
id="pos-guest"
value={guestName}
onChange={(e) => onGuestNameChange(e.target.value)}
placeholder={t("guestNamePlaceholder")}
className={compact ? "h-8 text-sm" : undefined}
/>
</LabeledField>
<LabeledField label={t("guestPhone")} htmlFor="pos-phone">
<Input
id="pos-phone"
value={guestPhone}
onChange={(e) => onGuestPhoneChange(e.target.value)}
placeholder={t("guestPhonePlaceholder")}
dir="ltr"
className={cn("text-end", compact && "h-8 text-sm")}
/>
</LabeledField>
<Button
type="submit"
variant="outline"
size="sm"
className="w-full"
disabled={!canSaveNew || createCustomer.isPending}
>
<UserPlus className="me-1.5 h-3.5 w-3.5" />
{createCustomer.isPending ? "..." : tCrm("addCustomer")}
</Button>
{!compact ? (
<p className="text-[10px] text-muted-foreground">{t("newCustomerHint")}</p>
) : null}
</form>
) : null}
{message ? (
<p className="text-center text-xs text-primary">{message}</p>
) : null}
</>
)}
</div>
);
}
@@ -0,0 +1,632 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { apiGet, apiPatch, apiPost, ApiClientError } from "@/lib/api/client";
import { printErrorMessage, printReceipt } from "@/lib/api/print";
import { requestPosPayment, posDeviceErrorMessage } from "@/lib/api/pos-device";
import { PosSlipModal } from "@/components/pos/pos-slip-modal";
import type { Customer, Order, Table, TableBoardItem } from "@/lib/api/types";
import { formatCurrency, formatNumber } from "@/lib/format";
import { formatPosOrderLabel } from "@/lib/pos-order-label";
import { formatOrderNumber } from "@/lib/order-number";
import { PosTableBoard } from "@/components/pos/pos-table-board";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
import { confirmPayLabel } from "@/lib/pos-confirm-pay-label";
import { useConfirm } from "@/components/providers/confirm-provider";
type PaymentRow = {
method: "Cash" | "Card" | "Credit";
amount: string;
};
type PosPayPanelProps = {
cafeId: string;
numberLocale: string;
branchId?: string | null;
};
export function PosPayPanel({ cafeId, numberLocale, branchId = null }: PosPayPanelProps) {
const t = useTranslations("pos");
const tPrint = useTranslations("print");
const tDashboard = useTranslations("dashboard");
const queryClient = useQueryClient();
const confirmDialog = useConfirm();
const { data: cafeSettings } = useCafeSettings(cafeId);
const cafeName = cafeSettings?.name ?? tDashboard("cafeName");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
const [filterTableId, setFilterTableId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [payMessage, setPayMessage] = useState<string | null>(null);
const [receiptOrder, setReceiptOrder] = useState<Order | null>(null);
const [lastPaidOrderId, setLastPaidOrderId] = useState<string | null>(null);
const [paymentRows, setPaymentRows] = useState<PaymentRow[]>([
{ method: "Cash", amount: "" },
]);
const [loyaltyRedeem, setLoyaltyRedeem] = useState(0);
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const { data: openOrders = [], isLoading } = useQuery({
queryKey: ["orders-open", cafeId, debouncedSearch],
queryFn: () => {
const qs = debouncedSearch
? `?search=${encodeURIComponent(debouncedSearch)}`
: "";
return apiGet<Order[]>(`/api/cafes/${cafeId}/orders/open${qs}`);
},
enabled: !!cafeId,
refetchInterval: 15_000,
});
const { data: tables = [] } = useQuery({
queryKey: ["tables", cafeId],
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables`),
enabled: !!cafeId,
});
const displayedOrders = useMemo(() => {
if (!filterTableId) return openOrders;
return openOrders.filter((o) => o.tableId === filterTableId);
}, [openOrders, filterTableId]);
useEffect(() => {
if (!cafeId) return;
const token =
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinCafe", cafeId))
.catch(() => undefined);
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
};
connection.on("TableStatusChanged", refresh);
connection.on("OrderStatusChanged", refresh);
return () => {
void connection.stop();
};
}, [cafeId, apiBase, queryClient]);
const selectOrder = (order: Order, tableId?: string | null) => {
setSelectedId(order.id);
setSelectedTableId(tableId ?? order.tableId ?? null);
setPayMessage(null);
};
const handleTableSelect = (table: TableBoardItem, activeOrder: Order | null) => {
setFilterTableId(table.id);
setSelectedTableId(table.id);
if (activeOrder) {
selectOrder(activeOrder, table.id);
return;
}
setSelectedId(null);
setPayMessage(t("noOrderOnTable"));
};
const selected = openOrders.find((o) => o.id === selectedId) ?? null;
const remaining = useMemo(() => {
if (!selected) return 0;
return Math.max(0, selected.total - (selected.paidAmount ?? 0));
}, [selected]);
const { data: payCustomer } = useQuery({
queryKey: ["customer", cafeId, selected?.customerId],
queryFn: () =>
apiGet<Customer>(`/api/cafes/${cafeId}/customers/${selected!.customerId}`),
enabled: !!cafeId && !!selected?.customerId,
});
const maxLoyaltyRedeem = useMemo(() => {
if (!payCustomer || !selected) return 0;
const byDue = Math.floor(remaining / 100);
return Math.min(payCustomer.loyaltyPoints, byDue);
}, [payCustomer, selected, remaining]);
const loyaltyDiscount = loyaltyRedeem * 100;
const effectiveRemaining = Math.max(0, remaining - loyaltyDiscount);
useEffect(() => {
setLoyaltyRedeem(0);
}, [selected?.id]);
useEffect(() => {
if (!selected) return;
setPaymentRows([{ method: "Cash", amount: String(effectiveRemaining) }]);
}, [selected?.id, selected?.total, selected?.paidAmount, effectiveRemaining]);
const payOrder = useMutation({
mutationFn: async (order: Order) => {
const payments = paymentRows
.map((row) => ({
method: row.method,
amount: parseFloat(row.amount.replace(/,/g, "")) || 0,
}))
.filter((p) => p.amount > 0);
if (payments.length === 0) throw new Error("no payments");
const cardTotal = payments
.filter((p) => p.method === "Card")
.reduce((s, p) => s + p.amount, 0);
const payBranchId = order.branchId ?? branchId;
if (cardTotal > 0 && payBranchId) {
await requestPosPayment(cafeId, payBranchId, order.id, cardTotal);
}
return apiPost(`/api/cafes/${cafeId}/orders/${order.id}/payments`, {
payments,
loyaltyPointsToRedeem: loyaltyRedeem > 0 ? loyaltyRedeem : undefined,
});
},
onSuccess: async (_data, order) => {
setPayMessage(t("paySuccess"));
setLastPaidOrderId(order.id);
try {
const paid = await apiGet<Order>(`/api/cafes/${cafeId}/orders/${order!.id}`);
setReceiptOrder(paid);
} catch {
setReceiptOrder(order ?? null);
}
setSelectedId(null);
setSelectedTableId(null);
setFilterTableId(null);
setSearch("");
setPaymentRows([{ method: "Cash", amount: "" }]);
setLoyaltyRedeem(0);
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-live", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
queryClient.invalidateQueries({ queryKey: ["customer", cafeId] });
},
onError: (err) => {
if (err instanceof ApiClientError) {
if (err.code.startsWith("POS_DEVICE")) {
setPayMessage(posDeviceErrorMessage(err, t));
return;
}
if (err.code === "NO_OPEN_SHIFT") {
setPayMessage(t("payNeedsOpenShift"));
return;
}
if (err.code === "LOYALTY_NO_CUSTOMER") {
setPayMessage(t("loyaltyNoCustomer"));
return;
}
if (err.code === "LOYALTY_INSUFFICIENT_POINTS") {
setPayMessage(t("loyaltyInsufficient"));
return;
}
setPayMessage(err.message || t("payError"));
return;
}
setPayMessage(t("payError"));
},
});
const cancelOrder = useMutation({
mutationFn: (orderId: string) =>
apiPatch(`/api/cafes/${cafeId}/orders/${orderId}/status`, {
status: "Cancelled",
}),
onSuccess: () => {
setPayMessage(t("cancelOrderSuccess"));
setSelectedId(null);
setSelectedTableId(null);
setFilterTableId(null);
setPaymentRows([{ method: "Cash", amount: "" }]);
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
},
onError: (err) => {
setPayMessage(
err instanceof ApiClientError ? err.message : t("cancelOrderError")
);
},
});
const paymentSum = paymentRows.reduce(
(s, row) => s + (parseFloat(row.amount.replace(/,/g, "")) || 0),
0
);
const canPay =
selected && paymentSum > 0 && paymentSum <= effectiveRemaining + 0.01;
const payButtonLabel = confirmPayLabel(paymentRows, t);
const thermalPrint = useMutation({
mutationFn: (orderId: string) => printReceipt(cafeId, orderId),
onSuccess: () => setPayMessage(tPrint("success")),
onError: (err) => setPayMessage(printErrorMessage(err, tPrint)),
});
return (
<div className="flex h-full min-h-0 w-full gap-4 overflow-hidden">
<Card className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<CardHeader className="shrink-0 space-y-3 pb-2">
<CardTitle className="text-base">{t("openOrders")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("payOpenOrdersHint")}</p>
<PosTableBoard
cafeId={cafeId}
numberLocale={numberLocale}
branchId={branchId}
mode="pay"
selectedTableId={selectedTableId}
selectedOrderId={selectedId}
onSelectTable={handleTableSelect}
/>
<LabeledField label={t("selectTable")} htmlFor="pay-table-filter">
<select
id="pay-table-filter"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={filterTableId ?? ""}
onChange={(e) => {
const id = e.target.value || null;
setFilterTableId(id);
setSelectedTableId(id);
if (!id) {
setSelectedId(null);
setPayMessage(null);
return;
}
void (async () => {
try {
const order = await apiGet<Order>(
`/api/cafes/${cafeId}/tables/${id}/active-order`
);
selectOrder(order, id);
} catch {
const match = openOrders.find((o) => o.tableId === id);
if (match) selectOrder(match, id);
else {
setSelectedId(null);
setPayMessage(t("noOrderOnTable"));
}
}
})();
}}
>
<option value="">{t("allTables")}</option>
{tables?.map((tbl) => (
<option key={tbl.id} value={tbl.id}>
{t("table")} {tbl.number}
</option>
))}
</select>
</LabeledField>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{t("payPickByName")}
</p>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchOpenOrder")}
className="h-9"
/>
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pt-0">
{payMessage && !selected ? (
<p className="mb-2 text-center text-sm text-amber-700">{payMessage}</p>
) : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">...</p>
) : displayedOrders.length === 0 ? (
<p className="text-sm text-muted-foreground">
{filterTableId ? t("noOpenOrdersOnTable") : t("noOpenOrders")}
</p>
) : (
<ul className="space-y-2">
{displayedOrders.map((order) => {
const label = formatPosOrderLabel(order, t("table"));
const isSelected = selectedId === order.id;
const guestLine =
order.guestName?.trim() ||
order.customerName?.trim() ||
order.guestPhone ||
order.customerPhone;
return (
<li key={order.id}>
<button
type="button"
onClick={() => selectOrder(order)}
className={cn(
"flex w-full flex-col gap-1 rounded-lg border border-border bg-card px-4 py-3 text-start shadow-sm transition hover:border-primary",
isSelected && "border-primary ring-1 ring-primary/30"
)}
>
<div className="flex w-full items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{label}</p>
{guestLine ? (
<p className="truncate text-xs text-[#0C447C]">
{guestLine}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{formatNumber(order.items.length, numberLocale)}{" "}
{t("itemsCount")} · {formatOrderNumber(order)}
</p>
</div>
<span className="shrink-0 text-sm font-bold text-primary">
{formatCurrency(order.total, numberLocale)}
</span>
</div>
</button>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
<Card className="flex h-full min-h-0 w-[min(100%,20rem)] shrink-0 flex-col overflow-hidden sm:w-72 lg:w-80">
<CardHeader className="shrink-0 space-y-2 pb-2">
<CardTitle className="text-lg">{t("payOrder")}</CardTitle>
{selected ? (
<div className="rounded-lg border border-[#0F6E56]/30 bg-[#E1F5EE] px-3 py-2">
<p className="text-xs text-muted-foreground">{t("payFor")}</p>
<p className="text-base font-semibold text-[#0F6E56]">
{formatPosOrderLabel(selected, t("table"))}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("selectOrderToPay")}</p>
)}
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden pt-2">
{selected ? (
<>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto overscroll-contain">
{selected.items.map((line) => (
<div
key={line.id}
className="flex justify-between gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm"
>
<span className="min-w-0 truncate">
{line.menuItemName} × {formatNumber(line.quantity, numberLocale)}
</span>
<span className="shrink-0 tabular-nums">
{formatCurrency(line.unitPrice * line.quantity, numberLocale)}
</span>
</div>
))}
</div>
<div className="shrink-0 space-y-2 border-t border-border pt-2">
<div className="flex justify-between text-sm">
<span>{t("subtotal")}</span>
<span>{formatCurrency(selected.subtotal, numberLocale)}</span>
</div>
{selected.discountAmount > 0 ? (
<div className="flex justify-between text-sm text-[#0F6E56]">
<span>{t("discount")}</span>
<span>-{formatCurrency(selected.discountAmount, numberLocale)}</span>
</div>
) : null}
<div className="flex justify-between text-sm">
<span>{t("tax")}</span>
<span>{formatCurrency(selected.taxTotal, numberLocale)}</span>
</div>
<div className="flex justify-between text-base font-bold">
<span>{t("total")}</span>
<span>{formatCurrency(selected.total, numberLocale)}</span>
</div>
{(selected.paidAmount ?? 0) > 0 ? (
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t("paidSoFar")}</span>
<span>{formatCurrency(selected.paidAmount, numberLocale)}</span>
</div>
) : null}
<div className="flex justify-between text-sm font-semibold text-primary">
<span>{t("remaining")}</span>
<span>{formatCurrency(effectiveRemaining, numberLocale)}</span>
</div>
{loyaltyDiscount > 0 ? (
<div className="flex justify-between text-sm text-[#0F6E56]">
<span>{t("loyaltyRedeemApplied")}</span>
<span>-{formatCurrency(loyaltyDiscount, numberLocale)}</span>
</div>
) : null}
{selected.customerId && payCustomer ? (
<div className="space-y-2 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/50 p-2">
<p className="text-xs font-medium text-[#0F6E56]">
{t("loyaltyBalance", {
points: formatNumber(payCustomer.loyaltyPoints, numberLocale),
})}
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
min={0}
max={maxLoyaltyRedeem}
value={loyaltyRedeem || ""}
onChange={(e) => {
const n = Math.min(
maxLoyaltyRedeem,
Math.max(0, parseInt(e.target.value, 10) || 0)
);
setLoyaltyRedeem(n);
}}
className="h-8 w-24 tabular-nums"
disabled={maxLoyaltyRedeem === 0}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
disabled={maxLoyaltyRedeem === 0}
onClick={() => setLoyaltyRedeem(maxLoyaltyRedeem)}
>
{t("loyaltyUseMax")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t("loyaltyRedeemHint")}</p>
</div>
) : null}
<div className="space-y-2 pt-1">
<p className="text-xs font-medium text-muted-foreground">
{t("splitPayments")}
</p>
{paymentRows.map((row, idx) => (
<div key={idx} className="flex gap-2">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={row.method}
onChange={(e) => {
const method = e.target.value as PaymentRow["method"];
setPaymentRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, method } : r))
);
}}
>
<option value="Cash">{t("cash")}</option>
<option value="Card">{t("card")}</option>
<option value="Credit">{t("credit")}</option>
</select>
<Input
dir="ltr"
className="h-9 flex-1 text-end tabular-nums"
value={row.amount}
onChange={(e) => {
const amount = e.target.value;
setPaymentRows((rows) =>
rows.map((r, i) => (i === idx ? { ...r, amount } : r))
);
}}
placeholder="0"
/>
{paymentRows.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setPaymentRows((rows) => rows.filter((_, i) => i !== idx))
}
>
×
</Button>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() =>
setPaymentRows((rows) => [
...rows,
{ method: "Card", amount: "" },
])
}
>
{t("addPaymentRow")}
</Button>
</div>
{payMessage ? (
<p className="text-center text-sm text-primary">{payMessage}</p>
) : null}
{lastPaidOrderId ? (
<Button
type="button"
variant="outline"
className="w-full"
disabled={thermalPrint.isPending}
onClick={() => thermalPrint.mutate(lastPaidOrderId)}
>
{thermalPrint.isPending ? "..." : tPrint("printReceipt")}
</Button>
) : null}
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setReceiptOrder(selected)}
>
{t("previewBill")}
</Button>
<Button
type="button"
variant="outline"
className="w-full border-[#A32D2D]/40 text-[#A32D2D] hover:bg-red-50"
disabled={cancelOrder.isPending}
onClick={async () => {
if (!selected) return;
const ok = await confirmDialog({
description: t("cancelOrderConfirm"),
variant: "destructive",
confirmLabel: t("cancelOrder"),
});
if (!ok) return;
cancelOrder.mutate(selected.id);
}}
>
{cancelOrder.isPending ? "..." : t("cancelOrder")}
</Button>
<form
className="w-full"
onSubmit={(e) => {
e.preventDefault();
if (canPay && selected && !payOrder.isPending) {
payOrder.mutate(selected);
}
}}
>
<Button
type="submit"
className="w-full"
disabled={!canPay || payOrder.isPending}
>
{payOrder.isPending ? "..." : payButtonLabel}
</Button>
</form>
</div>
</>
) : null}
</CardContent>
</Card>
{receiptOrder ? (
<PosSlipModal
variant="bill"
order={receiptOrder}
cafeName={cafeName}
onClose={() => setReceiptOrder(null)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,72 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { apiGet, apiPost } from "@/lib/api/client";
import type { QueueBoard } from "@/lib/api/types";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Link } from "@/i18n/routing";
type PosQueueBarProps = {
cafeId: string;
branchId: string | null;
};
export function PosQueueBar({ cafeId, branchId }: PosQueueBarProps) {
const t = useTranslations("queue");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const queryClient = useQueryClient();
const query = branchId ? `?branchId=${encodeURIComponent(branchId)}` : "";
const { data: board } = useQuery({
queryKey: ["queue-today", cafeId, branchId],
queryFn: () => apiGet<QueueBoard>(`/api/cafes/${cafeId}/queue/today${query}`),
enabled: !!cafeId,
refetchInterval: 15_000,
});
const callNext = useMutation({
mutationFn: () =>
apiPost<QueueBoard>(`/api/cafes/${cafeId}/queue/call-next${query}`, {}),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["queue-today"] }),
});
return (
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium text-primary">{t("title")}</span>
<span className="text-muted-foreground">
{t("nowServing")}:{" "}
<strong className="text-foreground tabular-nums">
{board?.nowServing != null
? formatNumber(board.nowServing, numberLocale)
: "—"}
</strong>
</span>
<span className="text-muted-foreground">
{t("lastIssued")}:{" "}
<strong className="tabular-nums">
{formatNumber(board?.lastIssued ?? 0, numberLocale)}
</strong>
</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={callNext.isPending || (board?.waitingCount ?? 0) === 0}
onClick={() => callNext.mutate()}
>
{t("callNext")}
</Button>
<Link
href="/queue"
className="text-xs text-primary underline-offset-2 hover:underline"
>
{t("issueNext")}
</Link>
</div>
);
}
@@ -0,0 +1,2 @@
export { PosReceiptModal, PosSlipModal } from "@/components/pos/pos-slip-modal";
export type { KitchenSlipLine } from "@/components/pos/pos-slip-modal";
@@ -0,0 +1,44 @@
@media print {
body * {
visibility: hidden;
}
#pos-slip-print-area,
#pos-slip-print-area *,
#receipt-print-area,
#receipt-print-area * {
visibility: visible;
}
#pos-slip-print-area,
#receipt-print-area {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
}
}
#pos-slip-print-area,
#receipt-print-area {
width: 80mm;
font-family: "Courier New", monospace;
font-size: 12px;
direction: rtl;
text-align: right;
padding: 4mm;
}
.receipt-divider {
border-top: 1px dashed #000;
margin: 3mm 0;
}
.receipt-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
}
.receipt-total {
font-weight: bold;
font-size: 14px;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
"use client";
import { useTranslations, useLocale } from "next-intl";
import type { Order } from "@/lib/api/types";
import { formatCurrency } from "@/lib/format";
import { formatOrderNumber } from "@/lib/order-number";
import { Button } from "@/components/ui/button";
import "./pos-receipt-print.css";
export type KitchenSlipLine = {
name: string;
quantity: number;
notes?: string;
};
type PosSlipModalProps = {
variant: "kitchen" | "bill";
cafeName: string;
onClose: () => void;
/** Full order for customer bill */
order?: Order;
/** Kitchen ticket lines (new items or full order) */
kitchenLines?: KitchenSlipLine[];
tableNumber?: string | number | null;
orderId?: string;
guestName?: string | null;
createdAt?: string;
};
export function PosSlipModal({
variant,
cafeName,
onClose,
order,
kitchenLines = [],
tableNumber,
orderId,
guestName,
createdAt,
}: PosSlipModalProps) {
const t = useTranslations("receipt");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const dateSource = order?.createdAt ?? createdAt ?? new Date().toISOString();
const formattedDate = new Intl.DateTimeFormat(
locale === "en" ? "en-US" : "fa-IR",
{ dateStyle: "short", timeStyle: "short" }
).format(new Date(dateSource));
const table =
order?.tableNumber ?? tableNumber ?? "—";
const orderNo = order ? formatOrderNumber(order) : orderId ? formatOrderNumber({ id: orderId }) : null;
const guest = order?.guestName ?? guestName;
const printId = "pos-slip-print-area";
const paymentKey = (method: string) => {
const m = method.toLowerCase();
if (m === "cash") return t("payment.cash");
if (m === "card") return t("payment.card");
if (m === "credit") return t("payment.credit");
return method;
};
const activeBillItems = order?.items.filter((i) => !i.isVoided) ?? [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-[340px] rounded-xl border border-border bg-background p-4 shadow-xl">
<div
id={printId}
className="mb-4 rounded-md border border-dashed border-border p-3"
>
<div className="text-center text-base font-bold">{cafeName}</div>
<div className="mb-1 text-center text-xs font-semibold">
{variant === "kitchen" ? t("kitchenTitle") : t("billTitle")}
</div>
<div className="mb-2 text-center text-xs text-muted-foreground">
{formattedDate}
</div>
<div className="text-xs">
{t("table")}: {table}
{orderNo ? (
<>
{" "}
| {t("order")}: #{orderNo}
</>
) : null}
</div>
{guest ? (
<div className="text-xs">
{t("guest")}: {guest}
</div>
) : null}
<div className="receipt-divider" />
{variant === "kitchen"
? kitchenLines.map((line, idx) => (
<div key={`${line.name}-${idx}`} className="receipt-row mb-1 text-xs">
<span>
{line.name} × {line.quantity}
{line.notes ? ` (${line.notes})` : ""}
</span>
</div>
))
: activeBillItems.map((item) => (
<div key={item.id} className="receipt-row mb-1 text-xs">
<span>
{item.menuItemName} × {item.quantity}
</span>
<span>
{formatCurrency(item.unitPrice * item.quantity, numberLocale)}
</span>
</div>
))}
{variant === "bill" ? (
<>
<div className="receipt-divider" />
<div className="receipt-row receipt-total">
<span>{t("total")}</span>
<span>{formatCurrency(order!.total, numberLocale)}</span>
</div>
{order!.payments?.map((p) => (
<div key={p.id} className="receipt-row mt-1 text-xs">
<span>{paymentKey(p.method)}</span>
<span>{formatCurrency(p.amount, numberLocale)}</span>
</div>
))}
<div className="receipt-divider" />
<div className="mt-2 text-center text-xs">{t("thankYou")}</div>
</>
) : (
<div className="mt-2 text-center text-[10px] text-muted-foreground">
{t("kitchenFooter")}
</div>
)}
</div>
<div className="flex gap-2">
<Button type="button" className="flex-1" onClick={() => window.print()}>
{t("print")}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={onClose}>
{t("close")}
</Button>
</div>
</div>
</div>
);
}
/** @deprecated Use PosSlipModal variant="bill" */
export function PosReceiptModal({
order,
cafeName,
onClose,
}: {
order: Order;
cafeName: string;
onClose: () => void;
}) {
return (
<PosSlipModal variant="bill" order={order} cafeName={cafeName} onClose={onClose} />
);
}
@@ -0,0 +1,290 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { apiGet, apiPatch } from "@/lib/api/client";
import {
branchTablesPath,
fetchCafeTableBoard,
setTableCleaning,
type TableSectionDto,
} from "@/lib/api/branch-tables";
import type { Order, TableBoardItem } from "@/lib/api/types";
import { formatCurrency } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
const statusStyles: Record<TableBoardItem["status"], string> = {
Free: "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/40 hover:border-[#0F6E56]",
Busy: "bg-blue-50 text-[#0C447C] border-blue-300 hover:border-blue-500",
Reserved: "bg-amber-50 text-[#BA7517] border-amber-300 hover:border-amber-500",
Cleaning: "bg-slate-100 text-slate-600 border-slate-300 hover:border-slate-500",
};
const selectedTableStyles =
"border-primary bg-primary/10 text-primary shadow-[0_0_0_2px_hsl(var(--primary)/0.35)] z-[1]";
type PosTableBoardProps = {
cafeId: string;
numberLocale: string;
selectedTableId: string | null;
selectedOrderId?: string | null;
branchId: string | null;
mode?: "order" | "pay";
onSelectTable: (table: TableBoardItem, activeOrder: Order | null) => void;
};
function groupPosTables(
tables: TableBoardItem[],
sections: TableSectionDto[],
noSectionLabel: string
): { key: string; label: string | null; tables: TableBoardItem[] }[] {
const groups: { key: string; label: string | null; tables: TableBoardItem[] }[] = [];
for (const sec of sections) {
const items = tables.filter((t) => t.sectionId === sec.id);
if (items.length > 0) {
groups.push({ key: sec.id, label: sec.name, tables: items });
}
}
const unassigned = tables.filter((t) => !t.sectionId);
if (unassigned.length > 0) {
groups.push({ key: "_none", label: noSectionLabel, tables: unassigned });
}
if (groups.length === 0 && tables.length > 0) {
groups.push({ key: "_all", label: null, tables });
}
return groups;
}
export function PosTableBoard({
cafeId,
numberLocale,
selectedTableId,
selectedOrderId = null,
branchId,
mode = "order",
onSelectTable,
}: PosTableBoardProps) {
const t = useTranslations("pos");
const tQr = useTranslations("qrMenu");
const tTables = useTranslations("tables");
const queryClient = useQueryClient();
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
const {
data: tables = [],
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["tables-board", cafeId, branchId, "pos"],
queryFn: () => fetchCafeTableBoard(cafeId, branchId),
enabled: !!cafeId,
});
const { data: sections = [] } = useQuery({
queryKey: ["table-sections", cafeId, branchId],
queryFn: () =>
apiGet<TableSectionDto[]>(
`${branchTablesPath(cafeId, branchId!)}/sections`
),
enabled: !!cafeId && !!branchId,
retry: false,
});
const grouped = useMemo(
() => groupPosTables(tables, sections, tTables("noSection")),
[tables, sections, tTables]
);
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
queryClient.invalidateQueries({ queryKey: ["orders-open", cafeId] });
}, [queryClient, cafeId]);
useEffect(() => {
if (!cafeId) return;
const token =
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinCafe", cafeId))
.catch(() => undefined);
connection.on("TableStatusChanged", refresh);
connection.on("OrderCreated", refresh);
connection.on("OrderStatusChanged", refresh);
return () => {
void connection.stop();
};
}, [cafeId, apiBase, refresh]);
const setCleaning = useMutation({
mutationFn: ({
tableId,
isCleaning,
tableBranchId,
}: {
tableId: string;
isCleaning: boolean;
tableBranchId: string;
}) => setTableCleaning(cafeId, tableId, isCleaning, branchId ?? tableBranchId),
onSuccess: () => refresh(),
});
const handleClick = async (table: TableBoardItem) => {
if (table.isCleaning ?? table.status === "Cleaning") return;
if (mode === "pay" && table.status !== "Busy") return;
let activeOrder: Order | null = null;
if (table.status === "Busy" && table.currentOrder?.orderId) {
try {
activeOrder = await apiGet<Order>(
`/api/cafes/${cafeId}/orders/${table.currentOrder.orderId}`
);
} catch {
try {
activeOrder = await apiGet<Order>(
`/api/cafes/${cafeId}/tables/${table.id}/active-order`
);
} catch {
activeOrder = null;
}
}
}
onSelectTable(table, activeOrder);
};
const statusLabel = (status: TableBoardItem["status"]) => {
switch (status) {
case "Free":
return tTables("status.free");
case "Busy":
return tTables("status.occupied");
case "Reserved":
return tTables("status.reserved");
case "Cleaning":
return tTables("status.cleaning");
}
};
const title =
mode === "pay" ? t("paySelectTable") : t("selectTableBoard");
const renderTableButton = (table: TableBoardItem) => {
const cleaning = table.isCleaning ?? table.status === "Cleaning";
const isSelected =
selectedTableId === table.id ||
(selectedOrderId != null &&
table.currentOrder?.orderId === selectedOrderId);
const payDisabled =
mode === "pay" &&
(table.status === "Cleaning" || table.status !== "Busy");
return (
<div key={table.id} className="flex shrink-0 flex-col gap-1">
<button
type="button"
disabled={cleaning || payDisabled}
onClick={() => void handleClick(table)}
className={cn(
"flex min-w-[4.5rem] flex-col items-center rounded-lg border-2 px-3 py-2 text-center transition",
isSelected ? selectedTableStyles : statusStyles[table.status],
(cleaning || payDisabled) &&
"cursor-not-allowed opacity-60"
)}
>
<span className="text-lg font-bold">{table.number}</span>
<span className="text-[10px]">{statusLabel(table.status)}</span>
{table.currentOrder && table.status === "Busy" ? (
<span className="mt-0.5 max-w-[4rem] truncate text-[10px] tabular-nums">
{formatCurrency(table.currentOrder.total, numberLocale)}
</span>
) : null}
{table.currentOrder?.guestLabel && table.status === "Busy" ? (
<span className="mt-0.5 max-w-[4.5rem] truncate text-[9px] opacity-90">
{table.currentOrder.guestLabel}
</span>
) : null}
{table.currentOrder?.source === "GuestQr" && table.status === "Busy" ? (
<span className="mt-0.5 rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-900">
{tQr("guestQrBadge")}
</span>
) : null}
</button>
{mode === "order" ? (
<button
type="button"
className="text-[10px] text-muted-foreground underline-offset-2 hover:underline"
onClick={(e) => {
e.stopPropagation();
setCleaning.mutate({
tableId: table.id,
tableBranchId: table.branchId,
isCleaning: !cleaning,
});
}}
>
{cleaning
? tTables("markReady")
: tTables("markCleaning")}
</button>
) : null}
</div>
);
};
return (
<div className="shrink-0 space-y-2 rounded-lg border border-border/80 bg-muted/20 p-3">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loadingTables")}</p>
) : null}
{isError ? (
<div className="space-y-2">
<p className="text-sm text-[#A32D2D]">{t("tablesLoadError")}</p>
<button
type="button"
className="text-xs text-[#0F6E56] underline-offset-2 hover:underline"
onClick={() => void refetch()}
>
{t("retryTables")}
</button>
</div>
) : null}
{!isLoading && !isError && tables.length === 0 ? (
<div className="space-y-2 rounded-md border border-dashed border-[#BA7517]/50 bg-amber-50/50 px-3 py-3">
<p className="text-sm text-[#BA7517]">{t("noTablesOnBoard")}</p>
<Link
href="/tables"
className="text-xs font-medium text-[#0F6E56] underline-offset-2 hover:underline"
>
{t("manageTablesLink")}
</Link>
</div>
) : null}
{!isLoading && !isError && tables.length > 0
? grouped.map((group) => (
<div key={group.key} className="space-y-1.5">
{group.label ? (
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{group.label}
</p>
) : null}
<div className="-mx-0.5 flex gap-2 overflow-x-auto px-1 py-1">
{group.tables.map(renderTableButton)}
</div>
</div>
))
: null}
</div>
);
}
@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { ConfirmProvider } from "@/components/providers/confirm-provider";
import { MeeziToaster } from "@/components/ui/meezi-toaster";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 1 },
},
})
);
return (
<QueryClientProvider client={queryClient}>
<ConfirmProvider>
{children}
<MeeziToaster />
</ConfirmProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,114 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslations } from "next-intl";
import { TriangleAlert } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
export type ConfirmOptions = {
title?: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "default" | "destructive";
};
type ConfirmContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export function ConfirmProvider({ children }: { children: ReactNode }) {
const t = useTranslations("confirm");
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolveRef = useRef<((value: boolean) => void) | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
});
}, []);
const finish = useCallback((value: boolean) => {
setOpen(false);
resolveRef.current?.(value);
resolveRef.current = null;
setTimeout(() => setOptions(null), 200);
}, []);
const value = useMemo(() => ({ confirm }), [confirm]);
const isDestructive = options?.variant === "destructive";
return (
<ConfirmContext.Provider value={value}>
{children}
<AlertDialog open={open} onOpenChange={(next) => !next && finish(false)}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<div className="flex items-start gap-3 sm:text-start">
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
isDestructive ? "bg-red-50 text-[#A32D2D]" : "bg-[#E1F5EE] text-[#0F6E56]"
)}
>
<TriangleAlert className="h-5 w-5" />
</span>
<div className="min-w-0 space-y-1.5 pt-0.5">
<AlertDialogTitle>
{options?.title ?? t("title")}
</AlertDialogTitle>
<AlertDialogDescription>{options?.description}</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-end">
<AlertDialogCancel onClick={() => finish(false)}>
{options?.cancelLabel ?? t("cancel")}
</AlertDialogCancel>
<AlertDialogAction
className={cn(
isDestructive &&
"bg-destructive text-destructive-foreground hover:opacity-90"
)}
onClick={() => finish(true)}
>
{options?.confirmLabel ?? t("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const ctx = useContext(ConfirmContext);
if (!ctx) {
throw new Error("useConfirm must be used within ConfirmProvider");
}
return ctx.confirm;
}
@@ -0,0 +1,880 @@
"use client";
import { Search, ShoppingBag, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { QR_ALL_CATEGORY_ID } from "@/lib/qr-menu-constants";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { CategoryVisual } from "@/components/menu/category-visual";
import { formatCurrency } from "@/lib/format";
import { resolveMediaUrl } from "@/lib/api/client";
import { cn } from "@/lib/utils";
import type { QrCartLine, QrPublicMenuCategory, QrPublicMenuItem } from "@/lib/api/qr-public";
import type { CafeThemePalette } from "@/lib/cafe-theme";
import { hasMenu3dView } from "@/lib/menu-3d";
import { Box } from "lucide-react";
export type QrMenuBodyProps = {
menuStyle: string;
colors: CafeThemePalette;
categories: QrPublicMenuCategory[];
activeCategory: string;
onCategoryChange: (id: string) => void;
activeItems: QrPublicMenuItem[];
showAllGrouped?: boolean;
searchQuery: string;
onSearchChange: (value: string) => void;
isSearching?: boolean;
categoryNameById?: Map<string, string>;
cart: QrCartLine[];
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
onView3d?: (item: QrPublicMenuItem) => void;
totalItems: number;
totalPrice: number;
onOpenCart: () => void;
labels: {
emptyCategory: string;
addToCart: string;
checkout: string;
searchPlaceholder: string;
allCategories: string;
clearSearch: string;
view3d: string;
};
};
export function QrGuestMenuBody({
showCartBar = true,
...props
}: QrMenuBodyProps & { showCartBar?: boolean }) {
const { colors } = props;
const primary = colors.primary;
const surface = colors.surface;
const style = props.menuStyle || "cards";
const listProps = {
...props,
primary,
surface,
colors,
showCategoryLabel: props.isSearching ?? false,
categoryNameById: props.categoryNameById,
};
return (
<div className="min-h-full">
<MenuSearchBar {...props} primary={primary} surface={surface} />
<CategoryTabs {...props} primary={primary} surface={surface} colors={colors} />
{props.showAllGrouped ? (
<GroupedAllSections {...listProps} />
) : style === "grid" ? (
<GridItems {...listProps} />
) : style === "list" ? (
<ListItems {...listProps} compact={false} />
) : style === "compact" ? (
<ListItems {...listProps} compact />
) : style === "magazine" ? (
<MagazineItems {...listProps} />
) : style === "classic" ? (
<ClassicLayout {...props} surface={surface} />
) : (
<CardItems {...listProps} />
)}
{showCartBar ? <CartBar {...props} floating={false} /> : null}
</div>
);
}
function MenuSearchBar({
searchQuery,
onSearchChange,
primary,
surface,
labels,
}: Pick<QrMenuBodyProps, "searchQuery" | "onSearchChange" | "labels"> & {
surface: string;
primary: string;
}) {
return (
<div
className="sticky top-0 z-20 px-3 pb-2 pt-2.5"
style={{ backgroundColor: surface }}
>
<div className="relative">
<Search
className="pointer-events-none absolute top-1/2 size-4 -translate-y-1/2 qr-icon start-3"
aria-hidden
/>
<Input
type="search"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={labels.searchPlaceholder}
className="h-10 rounded-xl qr-border qr-surface ps-9 pe-9 text-sm qr-text"
style={{ borderColor: `${primary}33` }}
/>
{searchQuery ? (
<button
type="button"
className="absolute top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-full qr-muted qr-fill-muted end-1"
onClick={() => onSearchChange("")}
aria-label={labels.clearSearch}
>
<X className="size-4 qr-icon" />
</button>
) : null}
</div>
</div>
);
}
function CategoryTabs({
categories,
activeCategory,
onCategoryChange,
primary,
surface,
colors,
labels,
}: Pick<QrMenuBodyProps, "categories" | "activeCategory" | "onCategoryChange" | "labels" | "colors"> & {
surface: string;
primary: string;
}) {
return (
<div
className="sticky top-[3.25rem] z-10 flex gap-2 overflow-x-auto border-b qr-border px-3 py-2.5 shadow-sm"
style={{ backgroundColor: surface }}
>
<button
type="button"
onClick={() => onCategoryChange(QR_ALL_CATEGORY_ID)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition active:scale-[0.98]",
activeCategory === QR_ALL_CATEGORY_ID ? "text-white" : "qr-border qr-text"
)}
style={
activeCategory === QR_ALL_CATEGORY_ID
? { backgroundColor: primary, borderColor: primary }
: { backgroundColor: "transparent", color: colors.text }
}
>
{labels.allCategories}
</button>
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => onCategoryChange(cat.id)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition active:scale-[0.98]",
activeCategory === cat.id ? "text-white" : "qr-border qr-text"
)}
style={
activeCategory === cat.id
? { backgroundColor: primary, borderColor: primary }
: { backgroundColor: "transparent", color: colors.text }
}
>
<CategoryVisual
icon={cat.icon}
iconPresetId={cat.iconPresetId}
iconStyle={cat.iconStyle}
imageUrl={cat.imageUrl}
size="xs"
brandColors={colors}
/>
{cat.name}
</button>
))}
</div>
);
}
type ItemListExtras = {
surface: string;
primary: string;
colors: CafeThemePalette;
showCategoryLabel?: boolean;
categoryNameById?: Map<string, string>;
};
function GroupedAllSections(
props: Pick<
QrMenuBodyProps,
"categories" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras
) {
const { categories, labels, surface, primary, colors, onView3d } = props;
const hasAny = categories.some((c) => (c.items?.length ?? 0) > 0);
if (!hasAny) return <EmptyCategory text={labels.emptyCategory} />;
return (
<div className="space-y-4 p-3 pb-4">
{categories.map((cat) => {
const items = cat.items ?? [];
if (items.length === 0) return null;
return (
<section key={cat.id}>
<p className="mb-2 px-1 text-[11px] font-medium uppercase tracking-[0.06em] qr-muted">
{cat.name}
</p>
<div className="space-y-2">
{items.map((item) => (
<ItemRowCard
key={item.id}
item={item}
cart={props.cart}
primary={primary}
surface={surface}
colors={colors}
onAdd={props.onAdd}
onRemove={props.onRemove}
onView3d={props.onView3d}
addLabel={labels.addToCart}
view3dLabel={labels.view3d}
/>
))}
</div>
</section>
);
})}
</div>
);
}
function CardItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras) {
return (
<div className="space-y-2 p-3 pb-4">
{activeItems.length === 0 ? (
<EmptyCategory text={labels.emptyCategory} />
) : (
activeItems.map((item) => (
<ItemRowCard
key={item.id}
item={item}
cart={cart}
primary={primary}
surface={surface}
colors={colors}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
addLabel={labels.addToCart}
view3dLabel={labels.view3d}
categoryLabel={
showCategoryLabel
? categoryNameById?.get(item.categoryId)
: undefined
}
/>
))
)}
</div>
);
}
function GridItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras) {
if (activeItems.length === 0) return <EmptyCategory text={labels.emptyCategory} />;
return (
<div className="grid grid-cols-2 gap-2.5 p-3 pb-4">
{activeItems.map((item) => (
<GridCard
key={item.id}
item={item}
cart={cart}
primary={primary}
surface={surface}
colors={colors}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
addLabel={labels.addToCart}
view3dLabel={labels.view3d}
categoryLabel={
showCategoryLabel ? categoryNameById?.get(item.categoryId) : undefined
}
/>
))}
</div>
);
}
function ListItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
compact,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras & { compact: boolean }) {
return (
<div className="space-y-2 p-3 pb-4">
{activeItems.length === 0 ? (
<EmptyCategory text={labels.emptyCategory} />
) : (
activeItems.map((item) => (
<div
key={item.id}
className={cn(
"flex items-center gap-2 rounded-lg border border-border/60 px-2 shadow-sm",
compact ? "py-1.5" : "py-2.5"
)}
style={{ backgroundColor: surface }}
>
<div className="relative shrink-0">
{resolveMediaUrl(item.imageUrl) ? (
<img
src={resolveMediaUrl(item.imageUrl)}
alt=""
className={cn(
"rounded-md object-cover",
compact ? "size-10" : "size-12"
)}
/>
) : (
<div
className={cn("rounded-md qr-fill-muted", compact ? "size-10" : "size-12")}
/>
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip
label={labels.view3d}
onClick={() => onView3d(item)}
className="absolute -bottom-1 end-0 scale-90"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
{showCategoryLabel && categoryNameById?.get(item.categoryId) ? (
<p className="mb-0.5 text-[10px] qr-muted">
{categoryNameById.get(item.categoryId)}
</p>
) : null}
<MenuItemLabels item={item} lines={1} primaryClassName="text-sm font-medium" />
<p className="text-xs font-semibold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</p>
</div>
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={labels.addToCart}
small
/>
</div>
))
)}
</div>
);
}
function MagazineItems({
activeItems,
cart,
onAdd,
onRemove,
onView3d,
primary,
labels,
surface,
colors,
showCategoryLabel,
categoryNameById,
}: Pick<
QrMenuBodyProps,
"activeItems" | "cart" | "onAdd" | "onRemove" | "onView3d" | "labels"
> &
ItemListExtras) {
return (
<div className="space-y-3 p-3 pb-4">
{activeItems.length === 0 ? (
<EmptyCategory text={labels.emptyCategory} />
) : (
activeItems.map((item) => {
const img = resolveMediaUrl(item.imageUrl);
return (
<article
key={item.id}
className="overflow-hidden rounded-xl border border-border/80 shadow-sm"
style={{ backgroundColor: surface }}
>
<div className="relative">
{img ? (
<img src={img} alt="" className="aspect-[16/9] w-full object-cover" />
) : (
<div className="aspect-[16/9] w-full qr-fill-muted" />
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip
label={labels.view3d}
onClick={() => onView3d(item)}
className="absolute bottom-3 start-3"
/>
) : null}
</div>
<div className="p-3">
{showCategoryLabel && categoryNameById?.get(item.categoryId) ? (
<p className="mb-1 text-[10px] qr-muted">
{categoryNameById.get(item.categoryId)}
</p>
) : null}
<MenuItemLabels item={item} lines={2} primaryClassName="text-base font-semibold" />
<div className="mt-2 flex items-center justify-between">
<span className="font-bold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</span>
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={labels.addToCart}
/>
</div>
</div>
</article>
);
})
)}
</div>
);
}
function ClassicLayout({
categories,
activeCategory,
onCategoryChange,
activeItems,
showAllGrouped,
cart,
onAdd,
onRemove,
onView3d,
colors,
labels,
surface,
}: QrMenuBodyProps & { surface: string }) {
const primary = colors.primary;
return (
<div className="flex min-h-[50vh]">
<aside
className="w-[4.5rem] shrink-0 space-y-2 border-e border-border/60 py-3"
style={{ backgroundColor: surface }}
>
<button
type="button"
onClick={() => onCategoryChange(QR_ALL_CATEGORY_ID)}
className={cn(
"mx-auto flex w-14 flex-col items-center gap-1 rounded-lg py-2 text-[10px] font-medium transition",
activeCategory === QR_ALL_CATEGORY_ID ? "text-white" : "qr-text"
)}
style={
activeCategory === QR_ALL_CATEGORY_ID
? { backgroundColor: primary }
: { color: colors.text }
}
>
<span className="text-base"></span>
<span className="line-clamp-2 text-center leading-tight">{labels.allCategories}</span>
</button>
{categories.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => onCategoryChange(cat.id)}
className={cn(
"mx-auto flex w-14 flex-col items-center gap-1 rounded-lg py-2 text-[10px] font-medium transition",
activeCategory === cat.id ? "text-white" : "qr-text"
)}
style={
activeCategory === cat.id
? { backgroundColor: primary }
: { color: colors.text }
}
>
<CategoryVisual
icon={cat.icon}
iconPresetId={cat.iconPresetId}
iconStyle={cat.iconStyle}
imageUrl={cat.imageUrl}
size="sm"
brandColors={colors}
/>
<span className="line-clamp-2 text-center leading-tight">{cat.name}</span>
</button>
))}
</aside>
<div className="min-w-0 flex-1">
{showAllGrouped ? (
<GroupedAllSections
categories={categories}
cart={cart}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
primary={primary}
labels={labels}
surface={surface}
colors={colors}
/>
) : (
<CardItems
activeItems={activeItems}
cart={cart}
onAdd={onAdd}
onRemove={onRemove}
onView3d={onView3d}
primary={primary}
labels={labels}
surface={surface}
colors={colors}
/>
)}
</div>
</div>
);
}
function ItemRowCard({
item,
cart,
primary,
surface,
colors,
onAdd,
onRemove,
onView3d,
addLabel,
view3dLabel,
categoryLabel,
}: {
item: QrPublicMenuItem;
cart: QrCartLine[];
primary: string;
surface: string;
colors: CafeThemePalette;
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
onView3d?: (item: QrPublicMenuItem) => void;
addLabel: string;
view3dLabel: string;
categoryLabel?: string;
}) {
const img = resolveMediaUrl(item.imageUrl);
return (
<div
className="flex gap-3 rounded-xl border border-border/70 p-3 shadow-sm"
style={{ backgroundColor: surface }}
>
<div className="relative shrink-0">
{img ? (
<img src={img} alt="" className="size-[4.5rem] rounded-lg object-cover" />
) : (
<div className="size-[4.5rem] rounded-lg qr-fill-muted" />
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip
label={view3dLabel}
onClick={() => onView3d(item)}
className="absolute bottom-1 end-1"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
{categoryLabel ? (
<p className="mb-0.5 text-[10px] font-medium qr-muted">{categoryLabel}</p>
) : null}
<div className="qr-text">
<MenuItemLabels item={item} lines={2} primaryClassName="text-sm font-semibold" />
</div>
{item.description ? (
<p className="mt-0.5 line-clamp-2 text-[11px] qr-muted">
{item.description}
</p>
) : null}
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-sm font-semibold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</span>
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={addLabel}
/>
</div>
</div>
</div>
);
}
function GridCard({
item,
cart,
primary,
surface,
colors,
onAdd,
onRemove,
onView3d,
addLabel,
view3dLabel,
categoryLabel,
}: {
item: QrPublicMenuItem;
cart: QrCartLine[];
primary: string;
surface: string;
colors: CafeThemePalette;
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
onView3d?: (item: QrPublicMenuItem) => void;
addLabel: string;
view3dLabel: string;
categoryLabel?: string;
}) {
const img = resolveMediaUrl(item.imageUrl);
return (
<article
className="flex flex-col overflow-hidden rounded-xl border border-border/80 shadow-sm"
style={{ backgroundColor: surface }}
>
<div className="relative">
{img ? (
<img src={img} alt="" className="aspect-square w-full object-cover" />
) : (
<div className="aspect-square w-full qr-fill-muted" />
)}
{hasMenu3dView(item) && onView3d ? (
<View3dChip label={view3dLabel} onClick={() => onView3d(item)} className="absolute bottom-2 start-2" />
) : null}
</div>
<div className="flex flex-1 flex-col p-2">
{categoryLabel ? (
<p className="mb-0.5 text-[10px] qr-muted">{categoryLabel}</p>
) : null}
<div className="qr-text">
<MenuItemLabels item={item} lines={2} primaryClassName="text-xs font-semibold" />
</div>
<p className="mt-1 text-xs font-bold" style={{ color: primary }}>
{formatCurrency(effectivePrice(item), "fa-IR")}
</p>
<div className="mt-auto pt-2">
<QtyControls
item={item}
cart={cart}
primary={primary}
onAdd={onAdd}
onRemove={onRemove}
addLabel={addLabel}
small
/>
</div>
</div>
</article>
);
}
function QtyControls({
item,
cart,
primary,
onAdd,
onRemove,
addLabel,
small,
}: {
item: QrPublicMenuItem;
cart: QrCartLine[];
primary: string;
onAdd: (item: QrPublicMenuItem) => void;
onRemove: (itemId: string) => void;
addLabel: string;
small?: boolean;
}) {
const inCart = cart.find((c) => c.item.id === item.id);
const size = small ? "size-7 text-base" : "size-8 text-lg";
if (inCart) {
return (
<div className="flex items-center gap-1.5">
<QtyBtn label="" className={size} variant="outline" color={primary} onClick={() => onRemove(item.id)} />
<span className="min-w-5 text-center text-sm font-bold">{inCart.qty}</span>
<QtyBtn label="+" className={size} variant="filled" color={primary} onClick={() => onAdd(item)} />
</div>
);
}
return (
<Button
size="sm"
className="h-8 rounded-full px-3 text-xs"
style={{ backgroundColor: primary }}
onClick={() => onAdd(item)}
>
{addLabel}
</Button>
);
}
function QtyBtn({
label,
className,
variant,
color,
onClick,
}: {
label: string;
className: string;
variant: "outline" | "filled";
color: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center rounded-full leading-none",
className,
variant === "filled" ? "text-white" : ""
)}
style={
variant === "filled"
? { backgroundColor: color }
: { border: `1.5px solid ${color}`, color }
}
>
{label}
</button>
);
}
function View3dChip({
label,
onClick,
className,
}: {
label: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn(
"flex items-center gap-1 rounded-md bg-black/70 px-2 py-1 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm transition active:scale-[0.98]",
className
)}
>
<Box className="h-3 w-3 shrink-0" aria-hidden />
{label}
</button>
);
}
export function QrFloatingCartBar(
props: Pick<QrMenuBodyProps, "totalItems" | "totalPrice" | "colors" | "onOpenCart" | "labels">
) {
return <CartBar {...props} floating />;
}
function CartBar({
totalItems,
totalPrice,
colors,
onOpenCart,
labels,
floating = true,
}: Pick<QrMenuBodyProps, "totalItems" | "totalPrice" | "colors" | "onOpenCart" | "labels"> & {
floating?: boolean;
}) {
const primary = colors.primary;
if (totalItems <= 0) return null;
return (
<div
className={cn(
floating
? "shadow-lg"
: "sticky bottom-0 z-20 border-t border-border/60 qr-surface/95 backdrop-blur"
)}
>
<Button
className="flex h-12 w-full items-center justify-between gap-3 rounded-2xl px-4 shadow-md"
style={{ backgroundColor: primary }}
onClick={onOpenCart}
>
<span className="flex size-7 items-center justify-center rounded-full bg-white/25 text-sm font-bold">
{totalItems.toLocaleString("fa-IR")}
</span>
<span className="flex items-center gap-2 font-semibold">
<ShoppingBag className="size-4 shrink-0 text-white" aria-hidden />
{labels.checkout}
</span>
<span className="text-sm font-bold">{formatCurrency(totalPrice, "fa-IR")}</span>
</Button>
</div>
);
}
function EmptyCategory({ text }: { text: string }) {
return <p className="p-8 text-center text-sm qr-muted">{text}</p>;
}
function effectivePrice(item: QrPublicMenuItem): number {
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
return Math.round(item.price * (1 - discount / 100));
}
@@ -0,0 +1,723 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { menuItemMatchesSearch } from "@/lib/menu-display";
import { QR_ALL_CATEGORY_ID } from "@/lib/qr-menu-constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { formatCurrency } from "@/lib/format";
import { resolveMediaUrl } from "@/lib/api/client";
import { ApiClientError } from "@/lib/api/client";
import {
callWaiter,
fetchBranchPublicMenu,
fetchPublicSecurityConfig,
placeBranchGuestOrder,
resolveQrCode,
type PublicSecurityConfig,
type QrCartLine,
type QrPublicMenuItem,
type QrResolve,
} from "@/lib/api/qr-public";
import {
buildQrThemeCssVars,
normalizeCafeTheme,
normalizeMenuTexture,
qrMenuTextureShellProps,
resolveQrGuestColors,
type CafeTheme,
} from "@/lib/cafe-theme";
import { QrFloatingCartBar, QrGuestMenuBody } from "@/components/qr/qr-guest-menu-body";
import { QrMenu3dSheet } from "@/components/qr/qr-menu-3d-sheet";
import { QrTurnstile } from "@/components/qr/qr-turnstile";
import { QrOrderTrack } from "@/components/qr/qr-order-track";
import {
loadGuestOrders,
ordersForTable,
saveGuestOrder,
type GuestOrderRef,
} from "@/lib/guest-order-storage";
import { cn } from "@/lib/utils";
type Screen = "loading" | "error" | "menu" | "cart" | "success" | "track" | "orders";
type QrGuestMenuProps = {
code: string;
};
export function QrGuestMenu({ code }: QrGuestMenuProps) {
const t = useTranslations("qrMenu");
const locale = useLocale();
const [screen, setScreen] = useState<Screen>("loading");
const [error, setError] = useState<string>("");
const [branch, setBranch] = useState<QrResolve | null>(null);
const [categories, setCategories] = useState<
Awaited<ReturnType<typeof fetchBranchPublicMenu>>["categories"]
>([]);
const [activeCategory, setActiveCategory] = useState("");
const [cart, setCart] = useState<QrCartLine[]>([]);
const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("");
const [orderNumber, setOrderNumber] = useState("");
const [activeTrack, setActiveTrack] = useState<{ orderId: string; token: string } | null>(null);
const [tableOrders, setTableOrders] = useState<GuestOrderRef[]>([]);
const [submitting, setSubmitting] = useState(false);
const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null);
const [security, setSecurity] = useState<PublicSecurityConfig | null>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [callWaiterState, setCallWaiterState] = useState<"idle" | "sending" | "sent" | "cooldown">("idle");
const themeColors = useMemo(
() => resolveQrGuestColors(menuTheme, branch?.primaryColor),
[menuTheme, branch?.primaryColor]
);
const primary = themeColors.primary;
const menuStyle = menuTheme?.menuStyle ?? "cards";
useEffect(() => {
let cancelled = false;
fetchPublicSecurityConfig()
.then((cfg) => {
if (!cancelled) setSecurity(cfg);
})
.catch(() => {
/* optional — orders still work when captcha is off */
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!code) return;
let cancelled = false;
(async () => {
try {
const resolved = await resolveQrCode(code);
if (cancelled) return;
if (resolved.isCleaning) {
setError(t("tableCleaning"));
setScreen("error");
return;
}
setBranch(resolved);
const menu = await fetchBranchPublicMenu(resolved.cafeId, resolved.branchId);
if (cancelled) return;
const cats = menu.categories ?? [];
setCategories(cats);
setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined));
setActiveCategory(QR_ALL_CATEGORY_ID);
if (cats.length === 0) {
setError(t("emptyMenu"));
setScreen("error");
return;
}
setScreen("menu");
setError("");
setTableOrders(ordersForTable(loadGuestOrders(), resolved.cafeId, resolved.tableId));
} catch (err) {
if (cancelled) return;
const message =
err instanceof ApiClientError
? err.code === "NOT_FOUND"
? t("tableNotFound")
: `${t("loadError")} (${err.message})`
: t("loadError");
setError(message);
setScreen("error");
}
})();
return () => {
cancelled = true;
};
}, [code, t]);
const totalItems = cart.reduce((s, c) => s + c.qty, 0);
const totalPrice = cart.reduce(
(s, c) => s + effectiveLinePrice(c.item) * c.qty,
0
);
const allItems = useMemo(
() => categories.flatMap((c) => c.items ?? []),
[categories]
);
const searchTrimmed = searchQuery.trim();
const isSearching = searchTrimmed.length > 0;
const showAllGrouped =
!isSearching && activeCategory === QR_ALL_CATEGORY_ID;
const activeItems = useMemo(() => {
const pool = isSearching
? allItems
: activeCategory === QR_ALL_CATEGORY_ID
? allItems
: categories.find((c) => c.id === activeCategory)?.items ?? [];
if (!isSearching) return pool;
return pool.filter((item) => menuItemMatchesSearch(item, searchTrimmed, locale));
}, [allItems, categories, activeCategory, isSearching, searchTrimmed, locale]);
const categoryNameById = useMemo(() => {
const map = new Map<string, string>();
for (const c of categories) map.set(c.id, c.name);
return map;
}, [categories]);
const addToCart = useCallback((item: QrPublicMenuItem) => {
setCart((prev) => {
const idx = prev.findIndex((c) => c.item.id === item.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx]!, qty: next[idx]!.qty + 1 };
return next;
}
return [...prev, { item, qty: 1 }];
});
}, []);
const removeFromCart = useCallback((itemId: string) => {
setCart((prev) => {
const idx = prev.findIndex((c) => c.item.id === itemId);
if (idx < 0) return prev;
const next = [...prev];
if (next[idx]!.qty > 1) {
next[idx] = { ...next[idx]!, qty: next[idx]!.qty - 1 };
return next;
}
next.splice(idx, 1);
return next;
});
}, []);
const refreshTableOrders = useCallback(() => {
if (!branch) return;
setTableOrders(
ordersForTable(loadGuestOrders(), branch.cafeId, branch.tableId)
);
}, [branch]);
useEffect(() => {
if (screen === "orders") refreshTableOrders();
}, [screen, refreshTableOrders]);
const handleCallWaiter = useCallback(async () => {
if (!branch || callWaiterState !== "idle") return;
setCallWaiterState("sending");
try {
await callWaiter(branch.cafeId, branch.tableId);
setCallWaiterState("sent");
setTimeout(() => setCallWaiterState("cooldown"), 2500);
setTimeout(() => setCallWaiterState("idle"), 62_000);
} catch (err) {
const code = err instanceof ApiClientError ? err.code : null;
setCallWaiterState(code === "RATE_LIMITED" ? "cooldown" : "idle");
if (code !== "RATE_LIMITED") setTimeout(() => setCallWaiterState("idle"), 3000);
}
}, [branch, callWaiterState]);
const captchaRequired =
!!security?.captchaRequired && !!security.turnstileSiteKey;
const submitOrder = async () => {
if (!branch || cart.length === 0) return;
if (captchaRequired && !captchaToken) {
setError(t("captchaRequired"));
return;
}
setSubmitting(true);
setError("");
try {
const result = await placeBranchGuestOrder(branch.cafeId, branch.branchId, {
tableId: branch.tableId,
guestName: guestName.trim() || null,
guestPhone: guestPhone.trim() || null,
captchaToken: captchaToken ?? undefined,
items: cart.map((c) => ({
menuItemId: c.item.id,
quantity: c.qty,
notes: c.note ?? null,
})),
});
setOrderNumber(result.orderNumber);
const orderRef: GuestOrderRef = {
orderId: result.orderId,
trackingToken: result.trackingToken,
orderNumber: result.orderNumber,
createdAt: new Date().toISOString(),
cafeId: branch.cafeId,
branchId: branch.branchId,
tableId: branch.tableId,
};
const saved = saveGuestOrder(orderRef);
setCart([]);
setCaptchaToken(null);
if (saved) {
refreshTableOrders();
} else {
setTableOrders((prev) => {
const filtered = prev.filter((o) => o.orderId !== orderRef.orderId);
return [orderRef, ...filtered];
});
}
setActiveTrack({ orderId: result.orderId, token: result.trackingToken });
setScreen("track");
} catch (err) {
if (err instanceof ApiClientError) {
if (err.code === "RATE_LIMITED") setError(t("rateLimited"));
else if (err.code?.startsWith("CAPTCHA")) setError(t("captchaRequired"));
else if (err.code === "CAFE_SUSPENDED") setError(t("cafeUnavailable"));
else setError(err.message || t("orderError"));
} else {
setError(t("orderError"));
}
setScreen("cart");
} finally {
setSubmitting(false);
}
};
if (screen === "loading") {
return (
<div
className="flex min-h-svh flex-col items-center justify-center gap-3 p-6"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<div
className="size-10 animate-spin rounded-full border-[3px] border-t-transparent"
style={{ borderColor: primary, borderTopColor: "transparent" }}
/>
<p className="text-sm qr-muted">{t("loading")}</p>
</div>
);
}
if (screen === "error") {
return (
<main
className="flex min-h-svh flex-col items-center justify-center p-6 text-center"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<p className="text-4xl">😕</p>
<p className="mt-4 font-medium qr-text">{error}</p>
<p className="mt-2 text-sm qr-muted">{t("scanAgain")}</p>
</main>
);
}
if (screen === "track" && activeTrack) {
return (
<main
className="mx-auto min-h-svh max-w-md"
dir="rtl"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<QrOrderTrack
orderId={activeTrack.orderId}
trackingToken={activeTrack.token}
primary={primary}
onBack={() => setScreen("menu")}
/>
<QrBottomNav
screen={screen}
primary={primary}
onMenu={() => setScreen("menu")}
onOrders={() => {
refreshTableOrders();
setScreen("orders");
}}
callWaiterState={callWaiterState}
onCallWaiter={() => void handleCallWaiter()}
/>
</main>
);
}
if (screen === "orders" && branch) {
return (
<main
className="mx-auto flex min-h-svh max-w-md flex-col"
dir="rtl"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<div className="flex-1 overflow-auto p-4">
<h2 className="mb-3 text-lg font-semibold qr-text">{t("myOrders")}</h2>
{tableOrders.length === 0 ? (
<p className="text-sm qr-muted">{t("noOrders")}</p>
) : (
<div className="space-y-2">
{tableOrders.map((o) => (
<button
key={o.orderId}
type="button"
className="w-full rounded-xl border qr-border qr-surface p-4 text-start transition"
style={{ borderColor: `color-mix(in srgb, ${primary} 35%, transparent)` }}
onClick={() => {
setActiveTrack({ orderId: o.orderId, token: o.trackingToken });
setScreen("track");
}}
>
<p className="font-medium qr-text">{o.orderNumber}</p>
<p className="text-xs qr-muted">
{new Date(o.createdAt).toLocaleString("fa-IR")}
</p>
</button>
))}
</div>
)}
</div>
<QrBottomNav
screen={screen}
primary={primary}
onMenu={() => setScreen("menu")}
onOrders={() => setScreen("orders")}
callWaiterState={callWaiterState}
onCallWaiter={() => void handleCallWaiter()}
/>
</main>
);
}
if (screen === "cart") {
return (
<div
className="mx-auto min-h-svh max-w-md p-4"
dir="rtl"
data-qr-guest-menu
style={buildQrThemeCssVars(themeColors)}
>
<header className="mb-4 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setScreen("menu")}>
</Button>
<h2 className="text-lg font-semibold qr-text">{t("cartTitle")}</h2>
</header>
<div className="rounded-xl border qr-border qr-surface">
{cart.map((c) => (
<div
key={c.item.id}
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0"
>
<div className="min-w-0 flex-1">
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
<p className="text-sm font-medium" style={{ color: primary }}>
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
</p>
</div>
<div className="flex items-center gap-2">
<QtyButton
label=""
onClick={() => removeFromCart(c.item.id)}
variant="outline"
color={primary}
/>
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
<QtyButton
label="+"
onClick={() => addToCart(c.item)}
variant="filled"
color={primary}
/>
</div>
</div>
))}
</div>
<div className="mt-4 space-y-2">
<Input
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder={t("guestName")}
className="text-end"
/>
<Input
value={guestPhone}
onChange={(e) => setGuestPhone(e.target.value)}
placeholder={t("guestPhone")}
inputMode="tel"
className="text-end"
/>
</div>
{captchaRequired && security?.turnstileSiteKey ? (
<div className="mt-4">
<QrTurnstile
siteKey={security.turnstileSiteKey}
onToken={(token) => {
setCaptchaToken(token);
if (error === t("captchaRequired")) setError("");
}}
onExpire={() => setCaptchaToken(null)}
/>
</div>
) : null}
{error ? (
<p className="mt-3 text-sm text-destructive">{error}</p>
) : null}
<div className="mt-4 rounded-xl border qr-border qr-surface p-4">
<div className="mb-3 flex justify-between font-semibold">
<span>{t("subtotal")}</span>
<span style={{ color: primary }}>
{formatCurrency(totalPrice, "fa-IR")}
</span>
</div>
<Button
className="w-full"
disabled={submitting}
style={{ backgroundColor: primary }}
onClick={() => void submitOrder()}
>
{submitting ? t("loading") : t("placeOrder")}
</Button>
</div>
</div>
);
}
const menuTexture = normalizeMenuTexture(menuTheme?.menuTexture);
const textureShell = qrMenuTextureShellProps(menuTexture, themeColors.background);
return (
<div
className="mx-auto flex min-h-svh max-w-md flex-col"
dir="rtl"
data-qr-guest-menu
data-qr-texture={textureShell["data-qr-texture"]}
style={{
...textureShell.style,
...buildQrThemeCssVars(themeColors),
}}
>
<header
className="border-b qr-border px-4 py-5 text-center qr-surface"
>
{branch?.logoUrl ? (
<img
src={resolveMediaUrl(branch.logoUrl)}
alt={branch.cafeName}
className="mx-auto mb-2 size-14 rounded-full object-cover"
/>
) : null}
<h1 className="text-lg font-bold qr-text">{branch?.cafeName}</h1>
<p className="text-sm qr-muted">{branch?.branchName}</p>
<p className="mt-1 text-xs qr-muted">
{branch?.welcomeText} {t("tableLabel")} {branch?.tableNumber}
</p>
</header>
<div
className={cn(
"min-h-0 flex-1 overflow-auto",
totalItems > 0 ? "pb-[8.5rem]" : "pb-20"
)}
>
<QrGuestMenuBody
showCartBar={false}
menuStyle={menuStyle}
colors={themeColors}
categories={categories}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
activeItems={activeItems}
showAllGrouped={showAllGrouped}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
isSearching={isSearching}
categoryNameById={categoryNameById}
cart={cart}
onAdd={addToCart}
onRemove={removeFromCart}
onView3d={setView3dItem}
totalItems={totalItems}
totalPrice={totalPrice}
onOpenCart={() => setScreen("cart")}
labels={{
emptyCategory: isSearching ? t("searchNoResults") : t("emptyCategory"),
addToCart: t("addToCart"),
checkout: t("placeOrder"),
searchPlaceholder: t("searchPlaceholder"),
allCategories: t("allCategories"),
clearSearch: t("clearSearch"),
view3d: t("view3d"),
}}
/>
</div>
{totalItems > 0 ? (
<div className="pointer-events-none fixed inset-x-0 bottom-[3.25rem] z-40 mx-auto max-w-md px-3 pb-1">
<div
className="pointer-events-auto rounded-2xl p-1 shadow-lg backdrop-blur-sm qr-surface"
style={{ backgroundColor: `color-mix(in srgb, ${themeColors.surface} 95%, transparent)` }}
>
<QrFloatingCartBar
totalItems={totalItems}
totalPrice={totalPrice}
colors={themeColors}
onOpenCart={() => setScreen("cart")}
labels={{
emptyCategory: "",
addToCart: t("addToCart"),
checkout: t("placeOrder"),
searchPlaceholder: "",
allCategories: "",
clearSearch: "",
view3d: "",
}}
/>
</div>
</div>
) : null}
{view3dItem ? (
<QrMenu3dSheet
item={view3dItem}
primary={primary}
onClose={() => setView3dItem(null)}
onAdd={() => addToCart(view3dItem)}
addLabel={t("addToCart")}
/>
) : null}
<QrBottomNav
screen={screen}
primary={primary}
onMenu={() => setScreen("menu")}
onOrders={() => {
refreshTableOrders();
setScreen("orders");
}}
callWaiterState={callWaiterState}
onCallWaiter={() => void handleCallWaiter()}
/>
</div>
);
}
function QrBottomNav({
screen,
primary,
onMenu,
onOrders,
callWaiterState,
onCallWaiter,
}: {
screen: Screen;
primary: string;
onMenu: () => void;
onOrders: () => void;
callWaiterState: "idle" | "sending" | "sent" | "cooldown";
onCallWaiter: () => void;
}) {
const t = useTranslations("qrMenu");
const callLabel =
callWaiterState === "sending"
? "..."
: callWaiterState === "sent"
? t("callWaiterSent")
: callWaiterState === "cooldown"
? t("callWaiterCooldown")
: t("callWaiter");
return (
<nav className="fixed inset-x-0 bottom-0 z-30 mx-auto flex max-w-md items-stretch border-t qr-border qr-surface">
<button
type="button"
className={cn(
"flex-1 py-3 text-sm font-medium",
screen === "menu" || screen === "cart" ? "qr-text" : "qr-muted"
)}
style={screen === "menu" || screen === "cart" ? { color: primary } : undefined}
onClick={onMenu}
>
{t("tabMenu")}
</button>
{/* Call waiter — centre prominent button */}
<div className="flex items-center justify-center px-2 py-1.5">
<button
type="button"
onClick={onCallWaiter}
disabled={callWaiterState !== "idle"}
className={cn(
"flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-semibold transition-all duration-200 shadow-md active:scale-95",
callWaiterState === "sent"
? "bg-emerald-500 text-white"
: callWaiterState === "cooldown"
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: callWaiterState === "sending"
? "opacity-70 cursor-wait text-white"
: "text-white"
)}
style={
callWaiterState === "idle" || callWaiterState === "sending"
? { backgroundColor: primary }
: undefined
}
>
<span
className={cn(
"inline-block transition-transform",
callWaiterState === "sent" && "animate-bounce"
)}
>
🔔
</span>
<span className="max-w-[7rem] truncate">{callLabel}</span>
</button>
</div>
<button
type="button"
className={cn(
"flex-1 py-3 text-sm font-medium",
screen === "orders" || screen === "track" ? "qr-text" : "qr-muted"
)}
style={screen === "orders" || screen === "track" ? { color: primary } : undefined}
onClick={onOrders}
>
{t("tabOrders")}
</button>
</nav>
);
}
function effectiveLinePrice(item: QrPublicMenuItem): number {
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
return Math.round(item.price * (1 - discount / 100));
}
function QtyButton({
label,
onClick,
variant,
color,
}: {
label: string;
onClick: () => void;
variant: "outline" | "filled";
color: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex size-8 items-center justify-center rounded-full text-lg leading-none ${
variant === "filled" ? "text-white" : ""
}`}
style={
variant === "filled"
? { backgroundColor: color }
: { border: `1.5px solid ${color}`, color }
}
>
{label}
</button>
);
}
@@ -0,0 +1,73 @@
"use client";
import { X } from "lucide-react";
import { useTranslations } from "next-intl";
import { MenuItemModelViewer } from "@/components/menu/menu-item-model-viewer";
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
import { formatCurrency } from "@/lib/format";
import type { QrPublicMenuItem } from "@/lib/api/qr-public";
import { Button } from "@/components/ui/button";
type QrMenu3dSheetProps = {
item: QrPublicMenuItem;
primary: string;
onClose: () => void;
onAdd: () => void;
addLabel: string;
};
export function QrMenu3dSheet({ item, primary, onClose, onAdd, addLabel }: QrMenu3dSheetProps) {
const t = useTranslations("qrMenu");
if (!item.model3dUrl) return null;
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/50 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label={t("view3d")}
>
<div className="mx-auto mt-auto flex w-full max-w-md flex-col rounded-t-2xl qr-surface shadow-2xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="min-w-0 flex-1">
<MenuItemLabels item={item} lines={1} primaryClassName="text-base font-semibold" />
<p className="text-sm font-medium" style={{ color: primary }}>
{formatCurrency(effectiveItemPrice(item), "fa-IR")}
</p>
</div>
<Button type="button" size="icon" variant="ghost" onClick={onClose} aria-label={t("close3d")}>
<X className="h-5 w-5" />
</Button>
</div>
<p className="px-4 pt-2 text-center text-xs qr-muted">{t("view3dHint")}</p>
<div className="min-h-[50vh] w-full px-2 pb-2">
<MenuItemModelViewer
modelUrl={item.model3dUrl}
posterUrl={item.imageUrl}
alt={item.name}
className="rounded-xl"
/>
</div>
<div className="border-t p-4">
<Button
type="button"
className="w-full text-white"
style={{ backgroundColor: primary }}
onClick={() => {
onAdd();
onClose();
}}
>
{addLabel}
</Button>
</div>
</div>
</div>
);
}
function effectiveItemPrice(item: QrPublicMenuItem): number {
const discount = item.discountPercent > 0 ? item.discountPercent : 0;
return Math.round(item.price * (1 - discount / 100));
}
@@ -0,0 +1,141 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { Check } from "lucide-react";
import { fetchOrderTrack, type QrOrderTrack } from "@/lib/api/qr-public";
import { cn } from "@/lib/utils";
import { formatCurrency } from "@/lib/format";
import { Button } from "@/components/ui/button";
type QrOrderTrackProps = {
orderId: string;
trackingToken: string;
primary: string;
onBack?: () => void;
};
export function QrOrderTrack({ orderId, trackingToken, primary, onBack }: QrOrderTrackProps) {
const t = useTranslations("qrMenu.tracking");
const [track, setTrack] = useState<QrOrderTrack | null>(null);
const [error, setError] = useState(false);
const load = useCallback(async () => {
try {
const data = await fetchOrderTrack(orderId, trackingToken);
setTrack(data);
setError(false);
} catch {
setError(true);
}
}, [orderId, trackingToken]);
useEffect(() => {
void load();
const id = setInterval(() => void load(), 8000);
return () => clearInterval(id);
}, [load]);
useEffect(() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${baseUrl}/hubs/guest-order`)
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinOrder", orderId, trackingToken))
.catch(() => undefined);
connection.on("OrderTrackUpdated", (payload: QrOrderTrack) => {
setTrack(payload);
});
return () => {
void connection.stop();
};
}, [orderId, trackingToken]);
if (error) {
return (
<p className="p-6 text-center text-sm qr-muted">{t("loadError")}</p>
);
}
if (!track) {
return (
<div className="flex justify-center p-8">
<div
className="size-8 animate-spin rounded-full border-2 border-t-transparent"
style={{ borderColor: primary, borderTopColor: "transparent" }}
/>
</div>
);
}
const statusKey = track.statusLabelKey;
return (
<div className="space-y-4 p-4">
{onBack ? (
<Button variant="ghost" size="sm" onClick={onBack}>
{t("back")}
</Button>
) : null}
<div className="rounded-xl border qr-border qr-surface p-4 text-center">
<p className="text-[11px] uppercase tracking-[0.06em] qr-muted">
{t("orderNumber")}
</p>
<p className="text-lg font-bold qr-text">{track.orderNumber}</p>
<p className="mt-2 text-sm font-medium" style={{ color: primary }}>
{t(`status.${statusKey}`)}
</p>
<p className="mt-1 text-xs qr-muted">
{formatCurrency(track.total, "fa-IR")}
{track.tableNumber ? ` · ${t("table")} ${track.tableNumber}` : ""}
</p>
</div>
<ol className="space-y-0 rounded-xl border qr-border qr-surface p-4">
{track.steps.map((step) => (
<li key={step.key} className="flex gap-3 pb-4 last:pb-0">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full border-2",
step.isComplete ? "border-transparent text-white" : "qr-border qr-fill-muted"
)}
style={step.isComplete ? { backgroundColor: primary } : undefined}
>
{step.isComplete ? <Check className="size-4" /> : null}
</div>
<div className="min-w-0 pt-1">
<p
className={cn(
"text-sm font-medium",
step.isCurrent && "qr-text",
!step.isCurrent && !step.isComplete && "qr-muted"
)}
>
{t(`steps.${step.labelKey}`)}
</p>
{step.isCurrent ? (
<p className="text-xs qr-muted">{t("currentStep")}</p>
) : null}
</div>
</li>
))}
</ol>
{statusKey === "ready" ? (
<p
className="rounded-lg px-3 py-2 text-center text-sm font-medium"
style={{ backgroundColor: `color-mix(in srgb, ${primary} 12%, #fff)`, color: primary }}
>
{t("readyHint")}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef } from "react";
type TurnstileApi = {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
theme?: "light" | "dark" | "auto";
}
) => string;
remove: (widgetId: string) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
const SCRIPT_ID = "cf-turnstile-script";
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
type QrTurnstileProps = {
siteKey: string;
onToken: (token: string) => void;
onExpire?: () => void;
};
export function QrTurnstile({ siteKey, onToken, onExpire }: QrTurnstileProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container || !siteKey) return;
let cancelled = false;
const renderWidget = () => {
if (cancelled || !containerRef.current || !window.turnstile) return;
if (widgetIdRef.current) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme: "auto",
callback: (token) => onToken(token),
"expired-callback": () => onExpire?.(),
"error-callback": () => onExpire?.(),
});
};
const ensureScript = () => {
if (window.turnstile) {
renderWidget();
return;
}
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", renderWidget);
return () => existing.removeEventListener("load", renderWidget);
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = renderWidget;
document.head.appendChild(script);
return () => {
script.onload = null;
};
};
const cleanupScript = ensureScript();
return () => {
cancelled = true;
cleanupScript?.();
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
}, [siteKey, onToken, onExpire]);
return <div ref={containerRef} className="flex justify-center py-2" />;
}
@@ -0,0 +1,90 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { Link } from "@/i18n/routing";
import { apiGet } from "@/lib/api/client";
import type { QueueBoard } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
export function QueueDisplayScreen() {
const t = useTranslations("queue");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const searchParams = useSearchParams();
const branchId = searchParams.get("branchId");
const branchQuery = branchId ? `?branchId=${encodeURIComponent(branchId)}` : "";
const { data: board } = useQuery({
queryKey: ["queue-today", cafeId, branchId, "display"],
queryFn: () => apiGet<QueueBoard>(`/api/cafes/${cafeId}/queue/today${branchQuery}`),
enabled: !!cafeId,
refetchInterval: 5_000,
});
const waiting = board?.tickets.filter((x) => x.status === "Waiting") ?? [];
const nowServing = board?.nowServing;
return (
<div className="flex min-h-svh flex-col bg-neutral-950 text-white">
<div className="flex items-center justify-between border-b border-white/10 px-6 py-3">
<p className="text-sm font-medium uppercase tracking-[0.12em] text-white/60">
{t("title")} · {t("displayMode")}
</p>
<Link
href="/queue"
className="text-xs text-white/50 underline-offset-2 hover:text-white hover:underline"
>
{t("exitDisplay")}
</Link>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-10 px-6 py-8">
<div className="text-center">
<p className="text-lg font-medium uppercase tracking-[0.14em] text-amber-400/90">
{t("nowServing")}
</p>
<p className="mt-2 text-[min(28vw,12rem)] font-bold tabular-nums leading-none text-white">
{nowServing != null ? formatNumber(nowServing, numberLocale) : "—"}
</p>
</div>
<div className="grid w-full max-w-4xl gap-6 sm:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/5 px-6 py-5 text-center">
<p className="text-xs uppercase tracking-wide text-white/50">{t("lastIssued")}</p>
<p className="mt-2 text-4xl font-semibold tabular-nums">
{formatNumber(board?.lastIssued ?? 0, numberLocale)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 px-6 py-5 text-center">
<p className="text-xs uppercase tracking-wide text-white/50">{t("displayWaitingLabel")}</p>
<p className="mt-2 text-4xl font-semibold tabular-nums text-amber-300">
{formatNumber(board?.waitingCount ?? 0, numberLocale)}
</p>
</div>
</div>
{waiting.length > 0 ? (
<div className="w-full max-w-3xl">
<p className="mb-3 text-center text-xs uppercase tracking-wide text-white/40">
{t("displayUpNext")}
</p>
<div className="flex flex-wrap justify-center gap-3">
{waiting.slice(0, 12).map((ticket) => (
<span
key={ticket.id}
className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-5 py-3 text-2xl font-bold tabular-nums"
>
{formatNumber(ticket.number, numberLocale)}
</span>
))}
</div>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
import { useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { apiGet, apiPost } from "@/lib/api/client";
import type { QueueBoard, QueueTicket, QueueTicketStatus } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { Link } from "@/i18n/routing";
import { Monitor } from "lucide-react";
import { formatNumber } from "@/lib/format";
const statusVariant: Record<QueueTicketStatus, string> = {
Waiting: "bg-amber-100 text-amber-900 border-amber-200",
Called: "bg-blue-100 text-blue-900 border-blue-200",
Done: "bg-slate-100 text-slate-600 border-slate-200",
Cancelled: "bg-red-50 text-red-800 border-red-200",
};
export function QueueScreen() {
const t = useTranslations("queue");
const tCommon = useTranslations("common");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const branchQuery = branchId ? `?branchId=${encodeURIComponent(branchId)}` : "";
const { data: board, isLoading } = useQuery({
queryKey: ["queue-today", cafeId, branchId],
queryFn: () =>
apiGet<QueueBoard>(`/api/cafes/${cafeId}/queue/today${branchQuery}`),
enabled: !!cafeId,
refetchInterval: 10_000,
});
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["queue-today", cafeId] });
}, [queryClient, cafeId]);
const issueNext = useMutation({
mutationFn: () =>
apiPost<QueueTicket>(`/api/cafes/${cafeId}/queue/next`, {
branchId: branchId ?? undefined,
customerLabel: label.trim() || undefined,
}),
onSuccess: () => {
setLabel("");
refresh();
},
});
const callNext = useMutation({
mutationFn: () =>
apiPost<QueueBoard>(`/api/cafes/${cafeId}/queue/call-next${branchQuery}`, {}),
onSuccess: () => refresh(),
});
if (!cafeId) return null;
const displayHref = branchId
? `/queue/display?branchId=${encodeURIComponent(branchId)}`
: "/queue/display";
return (
<div className="space-y-6">
<PageHeader
title={t("title")}
subtitle={t("subtitle")}
action={
<Button variant="outline" size="sm" asChild>
<Link href={displayHref} target="_blank" rel="noopener noreferrer">
<Monitor className="h-4 w-4 me-2" aria-hidden />
{t("openDisplay")}
</Link>
</Button>
}
/>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-xl border-2 border-primary/30 bg-primary/5 lg:col-span-1">
<CardContent className="flex flex-col items-center justify-center gap-2 pt-8 pb-8">
<p className="text-sm text-muted-foreground">{t("nowServing")}</p>
<p className="text-6xl font-bold tabular-nums text-primary">
{board?.nowServing != null
? formatNumber(board.nowServing, numberLocale)
: "—"}
</p>
<p className="text-xs text-muted-foreground">
{t("lastIssued")}: {formatNumber(board?.lastIssued ?? 0, numberLocale)}
</p>
<p className="text-xs text-muted-foreground">
{t("waitingCount", { count: board?.waitingCount ?? 0 })}
</p>
</CardContent>
</Card>
<Card className="rounded-xl lg:col-span-2">
<CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap gap-2">
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("customerLabelPlaceholder")}
className="min-w-[12rem] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !issueNext.isPending) {
e.preventDefault();
issueNext.mutate();
}
}}
/>
<Button
onClick={() => issueNext.mutate()}
disabled={issueNext.isPending}
>
{issueNext.isPending ? "..." : t("issueNext")}
</Button>
<Button
variant="outline"
onClick={() => callNext.mutate()}
disabled={callNext.isPending || (board?.waitingCount ?? 0) === 0}
>
{callNext.isPending ? "..." : t("callNext")}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("dailyResetHint")}</p>
</CardContent>
</Card>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : !board?.tickets.length ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{board.tickets.map((ticket) => (
<Card
key={ticket.id}
className={cn(
"rounded-lg border",
ticket.status === "Called" && "ring-2 ring-primary"
)}
>
<CardContent className="flex items-center justify-between gap-2 pt-4 pb-4">
<span className="text-2xl font-bold tabular-nums">
{formatNumber(ticket.number, numberLocale)}
</span>
<Badge
variant="outline"
className={cn("text-[10px]", statusVariant[ticket.status])}
>
{t(`status.${ticket.status}`)}
</Badge>
</CardContent>
{ticket.customerLabel ? (
<p className="truncate px-4 pb-3 text-xs text-muted-foreground">
{ticket.customerLabel}
</p>
) : null}
</Card>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
function ChartAreaSkeleton() {
return (
<div className="flex h-full flex-col justify-end gap-2 pt-4">
<div className="flex h-full items-end gap-1">
{[40, 65, 50, 80, 55, 70, 45].map((h, i) => (
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
<Skeleton className="h-3 w-full" />
</div>
);
}
function ChartPieSkeleton() {
return (
<div className="flex h-full items-center justify-center">
<Skeleton className="h-40 w-40 rounded-full" />
</div>
);
}
export function ReportsChartsFallback() {
const t = useTranslations("reports");
return (
<>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-xl border border-border/80 bg-card lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">{t("revenueChartTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
<ChartAreaSkeleton />
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("paymentMixTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
<ChartPieSkeleton />
</CardContent>
</Card>
</div>
</>
);
}
@@ -0,0 +1,212 @@
"use client";
import { useTranslations } from "next-intl";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
type LegendProps,
} from "recharts";
import { chartColor } from "@/lib/reports/analytics";
import { formatCurrency, formatNumber } from "@/lib/format";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
export type { ReportsChartPoint, ReportsPieSlice, ReportsChartsProps } from "@/components/reports/reports-charts.types";
export function ReportsCharts({
isLoading,
numberLocale,
chartData,
pieData,
branchCompareData,
showBranchCompare,
branches,
}: ReportsChartsProps) {
const t = useTranslations("reports");
return (
<>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-xl border border-border/80 bg-card lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">{t("revenueChartTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
{isLoading ? (
<ChartAreaSkeleton />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noData")}</p>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="revFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0F6E56" stopOpacity={0.35} />
<stop offset="95%" stopColor="#0F6E56" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e5e5" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 11 }}
tickFormatter={(v) => formatNumber(Number(v), numberLocale)}
width={56}
/>
<Tooltip
formatter={(value: number) => formatCurrency(value, numberLocale)}
labelFormatter={(_, payload) =>
payload?.[0]?.payload?.date
? String(payload[0].payload.date)
: ""
}
/>
<Area
type="monotone"
dataKey="revenue"
name={t("revenue")}
stroke="#0F6E56"
fill="url(#revFill)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("paymentMixTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
{isLoading ? (
<ChartPieSkeleton />
) : pieData.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("noData")}</p>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={48}
outerRadius={80}
paddingAngle={2}
>
{pieData.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
<Tooltip formatter={(value: number) => formatCurrency(value, numberLocale)} />
<Legend content={<ChartLegend />} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{showBranchCompare ? (
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("branchCompareTitle")}</CardTitle>
</CardHeader>
<CardContent className="h-80">
{isLoading ? (
<ChartBarSkeleton />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={branchCompareData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis
tickFormatter={(v) => formatNumber(Number(v), numberLocale)}
width={56}
/>
<Tooltip formatter={(value: number) => formatCurrency(value, numberLocale)} />
<Legend content={<ChartLegend />} />
{branches.map((b, i) => (
<Bar
key={b.id}
dataKey={b.id}
name={b.name}
fill={chartColor(i)}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
) : null}
</>
);
}
function ChartLegend({ payload }: LegendProps) {
if (!payload?.length) return null;
return (
<ul className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 pt-3">
{payload.map((entry, index) => (
<li
key={`legend-${String(entry.value)}-${index}`}
className="flex items-center gap-2 text-xs text-foreground"
>
<span
className="inline-block size-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: entry.color }}
aria-hidden
/>
<span className="leading-none">{entry.value}</span>
</li>
))}
</ul>
);
}
function ChartAreaSkeleton() {
return (
<div className="flex h-full flex-col justify-end gap-2 pt-4">
<div className="flex h-full items-end gap-1">
{[40, 65, 50, 80, 55, 70, 45].map((h, i) => (
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
<Skeleton className="h-3 w-full" />
</div>
);
}
function ChartPieSkeleton() {
return (
<div className="flex h-full items-center justify-center">
<Skeleton className="h-40 w-40 rounded-full" />
</div>
);
}
function ChartBarSkeleton() {
return (
<div className="flex h-full items-end gap-2 pt-4">
{[55, 70, 45, 80, 60].map((h, i) => (
<Skeleton key={i} className="flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
);
}
@@ -0,0 +1,22 @@
export type ReportsChartPoint = {
date: string;
label: string;
revenue: number;
};
export type ReportsPieSlice = {
key: string;
name: string;
value: number;
fill: string;
};
export type ReportsChartsProps = {
isLoading: boolean;
numberLocale: string;
chartData: ReportsChartPoint[];
pieData: ReportsPieSlice[];
branchCompareData: Array<Record<string, string | number>>;
showBranchCompare: boolean;
branches: { id: string; name: string }[];
};
@@ -0,0 +1,444 @@
"use client";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import { Download, TrendingDown, TrendingUp } from "lucide-react";
import { apiGet, ApiClientError } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import {
aggregateByDate,
branchComparisonPoints,
buildRangeFromPreset,
downloadReportsCsv,
isoTodayTehran,
percentChange,
previousPeriod,
revenueChartPoints,
sumSnapshots,
topProductsFromRange,
type DailyReportSnapshot,
type DateRangePreset,
type ReportRange,
} from "@/lib/reports/analytics";
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, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ReportsChartsFallback } from "@/components/reports/reports-charts-fallback";
import type { ReportsChartsProps } from "@/components/reports/reports-charts.types";
const LazyReportsCharts = lazy(() =>
import("@/components/reports/reports-charts").then((m) => ({
default: m.ReportsCharts,
}))
);
type Branch = { id: string; name: string };
const OWNER_ROLES = new Set(["Owner", "Manager"]);
const MULTI_BRANCH_PLANS = new Set(["Pro", "Business", "Enterprise"]);
export function ReportsScreen() {
const t = useTranslations("reports");
const locale = useLocale();
const rtl = locale === "fa" || locale === "ar";
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const planTier = useAuthStore((s) => s.user?.planTier ?? "Free");
const [range, setRange] = useState<ReportRange>(() => buildRangeFromPreset("7d"));
const [branchId, setBranchId] = useState<string | null>(null);
const [planError, setPlanError] = useState<string | null>(null);
const canViewAllBranches = OWNER_ROLES.has(role ?? "");
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (!canViewAllBranches && branchId === null && branches.length > 0) {
setBranchId(branches[0]!.id);
}
}, [canViewAllBranches, branchId, branches]);
const branchQuery = branchId ? `&branchId=${encodeURIComponent(branchId)}` : "";
const rangeKey = `${range.from}_${range.to}_${branchId ?? "all"}`;
const { data: snapshots = [], isLoading, isError, error } = useQuery({
queryKey: ["reports-daily-range", cafeId, rangeKey],
queryFn: async () => {
setPlanError(null);
return apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${range.from}&to=${range.to}${branchQuery}`
);
},
enabled: !!cafeId && !!range.from && !!range.to,
retry: false,
});
const prev = useMemo(
() => previousPeriod(range.from, range.to),
[range.from, range.to]
);
const { data: prevSnapshots = [] } = useQuery({
queryKey: ["reports-daily-range-prev", cafeId, prev.from, prev.to, branchId],
queryFn: () =>
apiGet<DailyReportSnapshot[]>(
`/api/cafes/${cafeId}/reports/daily/range?from=${prev.from}&to=${prev.to}${branchQuery}`
),
enabled: !!cafeId,
});
useEffect(() => {
if (isError && error instanceof ApiClientError && error.code === "PLAN_LIMIT_REACHED") {
setPlanError(error.message);
} else if (!isError) {
setPlanError(null);
}
}, [isError, error]);
const displayRows = useMemo(() => {
if (branchId) return [...snapshots].sort((a, b) => a.date.localeCompare(b.date));
return aggregateByDate(snapshots);
}, [snapshots, branchId]);
const prevRows = useMemo(() => {
if (branchId) return prevSnapshots;
return aggregateByDate(prevSnapshots);
}, [prevSnapshots, branchId]);
const totals = useMemo(() => sumSnapshots(displayRows), [displayRows]);
const prevTotals = useMemo(() => sumSnapshots(prevRows), [prevRows]);
const chartData = useMemo(
() => revenueChartPoints(displayRows, locale, rtl),
[displayRows, locale, rtl]
);
const pieData = useMemo(
() => [
{ key: "cash", name: t("cash"), value: totals.cashRevenue, fill: "#0F6E56" },
{ key: "card", name: t("card"), value: totals.cardRevenue, fill: "#0C447C" },
{ key: "credit", name: t("credit"), value: totals.creditRevenue, fill: "#BA7517" },
].filter((d) => d.value > 0),
[totals, t]
);
const topProducts = useMemo(
() => topProductsFromRange(displayRows, 10),
[displayRows]
);
const showBranchCompare =
!branchId &&
branches.length > 1 &&
OWNER_ROLES.has(role ?? "") &&
MULTI_BRANCH_PLANS.has(planTier);
const branchCompareData = useMemo(() => {
if (!showBranchCompare) return [];
return branchComparisonPoints(snapshots, branches, locale, rtl);
}, [showBranchCompare, snapshots, branches, locale, rtl]);
const branchNameMap = useMemo(
() => new Map(branches.map((b) => [b.id, b.name])),
[branches]
);
const setPreset = (preset: DateRangePreset) => {
setRange(buildRangeFromPreset(preset));
};
const handleExportCsv = () => {
const sorted = [...snapshots].sort(
(a, b) => a.date.localeCompare(b.date) || a.branchId.localeCompare(b.branchId)
);
downloadReportsCsv(
sorted,
branchNameMap,
{
date: t("csvDate"),
branch: t("csvBranch"),
totalRevenue: t("csvTotalRevenue"),
totalOrders: t("csvTotalOrders"),
avgOrderValue: t("csvAvgOrder"),
cashRevenue: t("csvCash"),
cardRevenue: t("csvCard"),
creditRevenue: t("csvCredit"),
netIncome: t("csvNetIncome"),
totalVoids: t("csvVoids"),
voidAmount: t("csvVoidAmount"),
totalExpenses: t("csvExpenses"),
},
`meezi-reports-${range.from}_${range.to}.csv`
);
};
if (!cafeId) return null;
return (
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
<PageHeader
title={t("title")}
subtitle={t("subtitle")}
action={
<Button
variant="outline"
className="border-[#0F6E56]/40"
onClick={handleExportCsv}
disabled={snapshots.length === 0}
>
<Download className="ms-2 h-4 w-4" />
{t("exportCsv")}
</Button>
}
/>
<Card className="rounded-xl border border-border/80 bg-card">
<CardContent className="flex flex-wrap items-end gap-4 pt-6">
<div className="flex flex-wrap gap-2">
{(["7d", "30d", "90d"] as const).map((preset) => (
<Button
key={preset}
size="sm"
variant={range.preset === preset ? "default" : "outline"}
className={cn(
range.preset === preset && "bg-[#0F6E56] hover:bg-[#0d5e49]"
)}
onClick={() => setPreset(preset)}
>
{t(`preset.${preset}`)}
</Button>
))}
</div>
<LabeledField label={t("fromDate")} htmlFor="report-from">
<Input
id="report-from"
type="date"
dir="ltr"
className="w-40 text-end"
value={range.from}
max={range.to}
onChange={(e) =>
setRange((r) => ({ ...r, from: e.target.value, preset: "custom" }))
}
/>
</LabeledField>
<LabeledField label={t("toDate")} htmlFor="report-to">
<Input
id="report-to"
type="date"
dir="ltr"
className="w-40 text-end"
value={range.to}
min={range.from}
max={isoTodayTehran()}
onChange={(e) =>
setRange((r) => ({ ...r, to: e.target.value, preset: "custom" }))
}
/>
</LabeledField>
{branches.length > 0 ? (
<LabeledField label={t("branch")} htmlFor="report-branch">
<select
id="report-branch"
className="h-9 min-w-[10rem] rounded-md border border-input bg-background px-3 text-sm"
value={branchId ?? ""}
onChange={(e) => setBranchId(e.target.value || null)}
>
{canViewAllBranches ? (
<option value="">{t("allBranches")}</option>
) : null}
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</LabeledField>
) : null}
</CardContent>
</Card>
{planError ? (
<p className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-[#BA7517]">
{planError}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<KpiCard
title={t("kpiTotalRevenue")}
value={isLoading ? "…" : formatCurrency(totals.totalRevenue, numberLocale)}
change={percentChange(totals.totalRevenue, prevTotals.totalRevenue)}
vsLabel={t("vsPrevious")}
numberLocale={numberLocale}
/>
<KpiCard
title={t("kpiTotalOrders")}
value={isLoading ? "…" : formatNumber(totals.totalOrders, numberLocale)}
change={percentChange(totals.totalOrders, prevTotals.totalOrders)}
vsLabel={t("vsPrevious")}
numberLocale={numberLocale}
/>
<KpiCard
title={t("kpiAvgOrder")}
value={isLoading ? "…" : formatCurrency(totals.avgOrderValue, numberLocale)}
change={percentChange(totals.avgOrderValue, prevTotals.avgOrderValue)}
vsLabel={t("vsPrevious")}
numberLocale={numberLocale}
/>
<KpiCard
title={t("kpiNetIncome")}
value={isLoading ? "…" : formatCurrency(totals.netIncome, numberLocale)}
change={percentChange(totals.netIncome, prevTotals.netIncome)}
vsLabel={t("vsPrevious")}
numberLocale={numberLocale}
/>
<KpiCard
title={t("kpiTotalExpenses")}
value={isLoading ? "…" : formatCurrency(totals.totalExpenses, numberLocale)}
change={percentChange(totals.totalExpenses, prevTotals.totalExpenses)}
vsLabel={t("vsPrevious")}
numberLocale={numberLocale}
valueClassName="text-[#BA7517]"
/>
</div>
<DeferredReportsCharts
isLoading={isLoading}
numberLocale={numberLocale}
chartData={chartData}
pieData={pieData}
branchCompareData={branchCompareData}
showBranchCompare={showBranchCompare}
branches={branches}
/>
<Card className="rounded-xl border border-border/80 bg-card">
<CardHeader>
<CardTitle className="text-base">{t("topProductsTitle")}</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[24rem] text-sm">
<thead>
<tr className="border-b text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
<th className="py-2 text-start">{t("colProduct")}</th>
<th className="py-2 text-end">{t("colQuantity")}</th>
<th className="py-2 text-end">{t("colRevenue")}</th>
</tr>
</thead>
<tbody>
{topProducts.length === 0 ? (
<tr>
<td colSpan={3} className="py-4 text-muted-foreground">
{t("noData")}
</td>
</tr>
) : (
topProducts.map((p, idx) => (
<tr key={p.productId} className="border-b border-border/50">
<td className="py-2.5">
<span className="me-2 text-muted-foreground">
{formatNumber(idx + 1, numberLocale)}.
</span>
{p.name}
</td>
<td className="py-2.5 text-end tabular-nums">
{formatNumber(p.quantity, numberLocale)}
</td>
<td className="py-2.5 text-end font-medium text-[#0F6E56] tabular-nums">
{formatCurrency(p.revenue, numberLocale)}
</td>
</tr>
))
)}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
function DeferredReportsCharts(props: ReportsChartsProps) {
const [ready, setReady] = useState(false);
useEffect(() => {
const id =
typeof requestIdleCallback !== "undefined"
? requestIdleCallback(() => setReady(true))
: window.setTimeout(() => setReady(true), 0);
return () => {
if (typeof requestIdleCallback !== "undefined" && typeof id === "number") {
cancelIdleCallback(id);
} else {
clearTimeout(id as number);
}
};
}, []);
if (!ready) {
return <ReportsChartsFallback />;
}
return (
<Suspense fallback={<ReportsChartsFallback />}>
<LazyReportsCharts {...props} />
</Suspense>
);
}
function KpiCard({
title,
value,
change,
vsLabel,
numberLocale,
valueClassName,
}: {
title: string;
value: string;
change: number | null;
vsLabel: string;
numberLocale: string;
valueClassName?: string;
}) {
const positive = change !== null && change >= 0;
return (
<Card className="rounded-xl border border-border/80 bg-card">
<CardContent className="space-y-1 pt-5">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{title}
</p>
<p className={cn("text-xl font-semibold text-foreground", valueClassName)}>{value}</p>
{change !== null ? (
<p
className={cn(
"flex items-center gap-1 text-xs",
positive ? "text-[#0F6E56]" : "text-[#A32D2D]"
)}
>
{positive ? (
<TrendingUp className="h-3.5 w-3.5" />
) : (
<TrendingDown className="h-3.5 w-3.5" />
)}
{formatNumber(Math.round(Math.abs(change) * 10) / 10, numberLocale)}% {vsLabel}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { Table } from "@/lib/api/types";
type ReservationStatus =
| "Pending"
| "Confirmed"
| "Cancelled"
| "Seated"
| "Completed";
interface Reservation {
id: string;
tableId?: string;
tableNumber?: string;
guestName: string;
guestPhone: string;
date: string;
time: string;
partySize: number;
status: ReservationStatus;
notes?: string;
}
const statusStyle: Record<ReservationStatus, string> = {
Pending: "bg-amber-50 text-[#BA7517] border-amber-200",
Confirmed: "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/30",
Seated: "bg-blue-50 text-blue-800 border-blue-200",
Completed: "bg-muted text-muted-foreground border-border",
Cancelled: "bg-red-50 text-[#A32D2D] border-red-200",
};
export function ReservationsScreen() {
const t = useTranslations("reservations");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("09121234567");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
const [time, setTime] = useState("19:00");
const [partySize, setPartySize] = useState("2");
const [tableId, setTableId] = useState("");
const [notes, setNotes] = useState("");
const { data: list = [], isLoading } = useQuery({
queryKey: ["reservations", cafeId],
queryFn: () => apiGet<Reservation[]>(`/api/cafes/${cafeId}/reservations`),
enabled: !!cafeId,
});
const { data: tables = [] } = useQuery({
queryKey: ["tables", cafeId],
queryFn: () => apiGet<Table[]>(`/api/cafes/${cafeId}/tables`),
enabled: !!cafeId,
});
const createReservation = useMutation({
mutationFn: () =>
apiPost<Reservation>(`/api/cafes/${cafeId}/reservations`, {
guestName: guestName.trim(),
guestPhone: guestPhone.trim(),
date,
time: time.length === 5 ? `${time}:00` : time,
partySize: Number(partySize),
tableId: tableId || null,
notes: notes.trim() || null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
setGuestName("");
setNotes("");
},
});
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: ReservationStatus }) =>
apiPatch(`/api/cafes/${cafeId}/reservations/${id}/status`, { status }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
});
if (!cafeId) return null;
const posHref = (r: Reservation) => {
const params = new URLSearchParams({ reservationId: r.id });
if (r.tableId) params.set("tableId", r.tableId);
params.set("guestName", r.guestName);
return `/pos?${params.toString()}`;
};
return (
<div className="space-y-6">
<h2 className="text-lg font-medium">{t("title")}</h2>
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("newReservation")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("newReservationHint")}</p>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<LabeledField label={t("guest")} htmlFor="res-guest">
<Input
id="res-guest"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("phone")} htmlFor="res-phone">
<Input
id="res-phone"
value={guestPhone}
onChange={(e) => setGuestPhone(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("table")} htmlFor="res-table">
<select
id="res-table"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={tableId}
onChange={(e) => setTableId(e.target.value)}
>
<option value="">{t("tableOptional")}</option>
{tables.map((tbl) => (
<option key={tbl.id} value={tbl.id}>
{t("tableNumber", { number: tbl.number })}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("date")} htmlFor="res-date">
<Input
id="res-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("time")} htmlFor="res-time">
<Input
id="res-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("party")} htmlFor="res-party">
<Input
id="res-party"
type="number"
min={1}
max={20}
value={partySize}
onChange={(e) => setPartySize(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("notes")} htmlFor="res-notes" className="sm:col-span-2 lg:col-span-3">
<Input
id="res-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</LabeledField>
<div className="sm:col-span-2 lg:col-span-3">
<Button
onClick={() => createReservation.mutate()}
disabled={!guestName.trim() || createReservation.isPending}
>
{createReservation.isPending ? "..." : t("create")}
</Button>
</div>
</CardContent>
</Card>
{isLoading ? (
<p className="text-sm text-muted-foreground">...</p>
) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="space-y-2">
{list.map((r) => (
<li key={r.id}>
<Card className="rounded-xl border border-border/80">
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6">
<div>
<p className="font-medium">{r.guestName}</p>
<p className="text-[11px] text-muted-foreground">
{r.date} {r.time.slice(0, 5)} · {formatNumber(r.partySize)} {t("party")}
{r.tableNumber ? ` · ${t("tableNumber", { number: r.tableNumber })}` : ""}
</p>
<p className="text-[11px] text-muted-foreground">{r.guestPhone}</p>
</div>
<Badge className={cn("border", statusStyle[r.status])}>
{t(`status.${r.status}`)}
</Badge>
<div className="flex flex-wrap gap-2">
{r.status === "Pending" && (
<>
<Button
size="sm"
onClick={() => updateStatus.mutate({ id: r.id, status: "Confirmed" })}
>
{t("confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Cancelled" })}
>
{t("cancel")}
</Button>
</>
)}
{(r.status === "Confirmed" || r.status === "Seated") && (
<Button size="sm" asChild>
<Link href={posHref(r)}>{t("openPos")}</Link>
</Button>
)}
{r.status === "Seated" && (
<Button
size="sm"
variant="outline"
onClick={() => updateStatus.mutate({ id: r.id, status: "Completed" })}
>
{t("markCompleted")}
</Button>
)}
</div>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Star } from "lucide-react";
import { apiGet, apiPatch } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type CafeReview = {
id: string;
authorName: string;
rating: number;
comment: string | null;
ownerReply: string | null;
createdAt: string;
};
function Stars({ rating }: { rating: number }) {
return (
<span className="inline-flex gap-0.5 text-amber-500" aria-label={`${rating}/5`}>
{[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
className="h-4 w-4"
fill={n <= rating ? "currentColor" : "none"}
strokeWidth={n <= rating ? 0 : 1.5}
/>
))}
</span>
);
}
export function ReviewsScreen() {
const t = useTranslations("reviews");
const tCommon = useTranslations("common");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [replyingId, setReplyingId] = useState<string | null>(null);
const [replyText, setReplyText] = useState("");
const { data: reviews = [], isLoading } = useQuery({
queryKey: ["cafe-reviews", cafeId],
queryFn: () => apiGet<CafeReview[]>(`/api/cafes/${cafeId}/reviews?pageSize=50`),
enabled: !!cafeId,
});
const replyMutation = useMutation({
mutationFn: ({ reviewId, reply }: { reviewId: string; reply: string }) =>
apiPatch<CafeReview>(`/api/cafes/${cafeId}/reviews/${reviewId}/reply`, { reply }),
onSuccess: () => {
setReplyingId(null);
setReplyText("");
queryClient.invalidateQueries({ queryKey: ["cafe-reviews", cafeId] });
},
});
if (!cafeId) return null;
const avg =
reviews.length > 0
? Math.round((reviews.reduce((s, r) => s + r.rating, 0) / reviews.length) * 10) / 10
: 0;
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">{t("title")}</h2>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("summary")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-4">
<div>
<p className="text-3xl font-bold text-primary">{formatNumber(avg)}</p>
<Stars rating={Math.round(avg)} />
</div>
<p className="text-sm text-muted-foreground">
{t("reviewCount", { count: formatNumber(reviews.length) })}
</p>
</CardContent>
</Card>
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="space-y-3">
{reviews.map((review) => (
<li key={review.id}>
<Card>
<CardContent className="space-y-3 pt-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="font-medium">{review.authorName}</p>
<Stars rating={review.rating} />
</div>
<time className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleDateString("fa-IR")}
</time>
</div>
{review.comment && (
<p className="text-sm text-foreground">{review.comment}</p>
)}
{review.ownerReply ? (
<div className="rounded-md bg-muted p-3 text-sm">
<p className="mb-1 font-medium text-primary">{t("ownerReply")}</p>
<p>{review.ownerReply}</p>
</div>
) : replyingId === review.id ? (
<div className="space-y-2">
<LabeledField label={t("reply")} htmlFor={`reply-${review.id}`}>
<textarea
id={`reply-${review.id}`}
className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
/>
</LabeledField>
<div className="flex gap-2">
<Button
size="sm"
disabled={!replyText.trim() || replyMutation.isPending}
onClick={() =>
replyMutation.mutate({ reviewId: review.id, reply: replyText })
}
>
{tCommon("save")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setReplyingId(null);
setReplyText("");
}}
>
{tCommon("cancel")}
</Button>
</div>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReplyingId(review.id)}>
{t("reply")}
</Button>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CafeTheme } from "@/lib/cafe-theme";
import { normalizeMenuTexture, resolveThemeColors } from "@/lib/cafe-theme";
import { qrMenuTextureShellProps } from "@/lib/qr-menu-texture";
const PREVIEW_SAMPLES = [
{ name: "اسپرسو", price: "۸۵٬۰۰۰ ت" },
{ name: "کاپوچینو", price: "۱۲۰٬۰۰۰ ت" },
] as const;
type GuestMenuTemplatePreviewProps = {
theme: CafeTheme;
cafeName?: string;
};
export function GuestMenuTemplatePreview({
theme,
cafeName = "کافه نمونه",
}: GuestMenuTemplatePreviewProps) {
const t = useTranslations("settings.appearance");
const colors = resolveThemeColors(theme);
const style = theme.menuStyle;
const textureShell = qrMenuTextureShellProps(
normalizeMenuTexture(theme.menuTexture),
colors.background
);
return (
<div className="flex flex-col items-center gap-3">
<p className="text-xs text-muted-foreground">{t("guestMenuPreviewHint")}</p>
<div
className="relative w-[220px] overflow-hidden rounded-[1.75rem] border-[6px] border-neutral-800 bg-neutral-900 shadow-xl"
dir="rtl"
>
<div
className="h-[380px] overflow-hidden"
data-qr-texture={textureShell["data-qr-texture"]}
style={textureShell.style}
>
<div
className="border-b px-3 py-3 text-center"
style={{ backgroundColor: colors.surface }}
>
<div
className="mx-auto mb-1 size-8 rounded-full"
style={{ backgroundColor: colors.primary }}
/>
<p className="text-[11px] font-bold" style={{ color: colors.text }}>
{cafeName}
</p>
<p className="text-[9px]" style={{ color: colors.textMuted }}>
{t(`menuStyles.${style}`)} · {t(`menuTextures.${normalizeMenuTexture(theme.menuTexture)}`)}
</p>
</div>
{style === "classic" ? (
<div className="flex h-[calc(100%-4.5rem)]">
<div
className="flex w-12 flex-col gap-1 border-e py-2"
style={{ backgroundColor: colors.surface }}
>
{["☕", "🍰", "🥤"].map((icon, i) => (
<div
key={icon}
className={cn(
"mx-auto flex size-8 items-center justify-center rounded-md text-xs",
i === 0 ? "text-white" : ""
)}
style={i === 0 ? { backgroundColor: colors.primary } : undefined}
>
{icon}
</div>
))}
</div>
<PreviewItems colors={colors} layout="list" />
</div>
) : style === "grid" ? (
<div className="grid grid-cols-2 gap-1.5 p-2">
{PREVIEW_SAMPLES.map((item) => (
<PreviewCard key={item.name} item={item} colors={colors} />
))}
</div>
) : style === "magazine" ? (
<div className="space-y-2 p-2">
{PREVIEW_SAMPLES.map((item) => (
<div
key={item.name}
className="overflow-hidden rounded-lg border shadow-sm"
style={{
borderColor: `${colors.textMuted}33`,
backgroundColor: colors.surface,
}}
>
<div
className="h-14"
style={{ backgroundColor: colors.secondary }}
/>
<div className="p-1.5">
<p className="text-[10px] font-semibold">{item.name}</p>
<p className="text-[9px]" style={{ color: colors.primary }}>
{item.price}
</p>
</div>
</div>
))}
</div>
) : (
<PreviewItems
colors={colors}
layout={style === "compact" ? "compact" : "list"}
/>
)}
</div>
</div>
</div>
);
}
function PreviewItems({
colors,
layout,
}: {
colors: ReturnType<typeof resolveThemeColors>;
layout: "list" | "compact";
}) {
return (
<div className="flex-1 space-y-1.5 p-2">
{PREVIEW_SAMPLES.map((item) => (
<div
key={item.name}
className={cn(
"flex items-center justify-between rounded border px-2 shadow-sm",
layout === "compact" ? "py-1" : "py-1.5"
)}
style={{
borderColor: `${colors.textMuted}33`,
backgroundColor: colors.surface,
}}
>
<span className="text-[10px] font-medium">{item.name}</span>
<span className="text-[9px] font-semibold" style={{ color: colors.primary }}>
{item.price}
</span>
</div>
))}
</div>
);
}
function PreviewCard({
item,
colors,
}: {
item: (typeof PREVIEW_SAMPLES)[number];
colors: ReturnType<typeof resolveThemeColors>;
}) {
return (
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: `${colors.textMuted}33`, backgroundColor: colors.surface }}
>
<div className="aspect-square" style={{ backgroundColor: colors.secondary }} />
<div className="p-1">
<p className="text-[9px] font-semibold">{item.name}</p>
<p className="text-[8px]" style={{ color: colors.primary }}>
{item.price}
</p>
</div>
</div>
);
}
@@ -0,0 +1,474 @@
"use client";
import { useTranslations } from "next-intl";
import { Check, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatCurrency, formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
const PRICES: Record<PlanId, number | null> = {
Free: 0,
Pro: 1_490_000,
Business: 3_490_000,
Enterprise: null,
};
type CellValue =
| { kind: "bool"; value: boolean }
| { kind: "limit"; value: number | null }
| { kind: "text"; value: string };
type FeatureRow = {
key: string;
cells: Record<PlanId, CellValue>;
};
const FEATURE_MATRIX: FeatureRow[] = [
{
key: "ordersPerDay",
cells: {
Free: { kind: "limit", value: 50 },
Pro: { kind: "limit", value: null },
Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "terminals",
cells: {
Free: { kind: "limit", value: 1 },
Pro: { kind: "limit", value: 3 },
Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "crmCustomers",
cells: {
Free: { kind: "limit", value: 50 },
Pro: { kind: "limit", value: null },
Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "smsPerMonth",
cells: {
Free: { kind: "limit", value: 0 },
Pro: { kind: "limit", value: 50 },
Business: { kind: "limit", value: 200 },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "branches",
cells: {
Free: { kind: "limit", value: 1 },
Pro: { kind: "limit", value: 1 },
Business: { kind: "limit", value: 5 },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "posKds",
cells: {
Free: { kind: "bool", value: true },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "tablesQr",
cells: {
Free: { kind: "bool", value: true },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "menuReservations",
cells: {
Free: { kind: "bool", value: true },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "reports",
cells: {
Free: { kind: "text", value: "basic" },
Pro: { kind: "text", value: "full" },
Business: { kind: "text", value: "full" },
Enterprise: { kind: "text", value: "full" },
},
},
{
key: "hrModule",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "snappfoodDelivery",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "tarazTax",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "badges",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: false },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "whiteLabel",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: false },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "apiAccess",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: false },
Enterprise: { kind: "bool", value: true },
},
},
];
function CellDisplay({
cell,
t,
numberLocale,
}: {
cell: CellValue;
t: ReturnType<typeof useTranslations<"settings.plans">>;
numberLocale: string;
}) {
if (cell.kind === "bool") {
return cell.value ? (
<Check className="mx-auto h-5 w-5 text-[#0F6E56]" aria-hidden />
) : (
<X className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />
);
}
if (cell.kind === "limit") {
if (cell.value === null) {
return (
<span className="text-sm font-medium text-[#0F6E56]">{t("unlimited")}</span>
);
}
if (cell.value === 0) {
return <Minus className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />;
}
return (
<span className="text-sm font-medium">{formatNumber(cell.value, numberLocale)}</span>
);
}
return (
<span className="text-xs font-medium text-muted-foreground">
{t(`levels.${cell.value}`)}
</span>
);
}
type PlanComparisonProps = {
currentPlan?: string;
onSubscribe: (planTier: "Pro" | "Business") => void;
isSubscribing?: boolean;
};
export function PlanComparison({
currentPlan = "Free",
onSubscribe,
isSubscribing = false,
}: PlanComparisonProps) {
const t = useTranslations("settings.plans");
const tSettings = useTranslations("settings");
const numberLocale =
typeof document !== "undefined" && document.documentElement.lang === "en"
? "en-US"
: "fa-IR";
const normalizedCurrent = currentPlan as PlanId;
return (
<section className="relative z-0 mb-8 space-y-4 scroll-mt-6">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("compareLabel")}
</p>
<h3 className="text-lg font-medium text-foreground">{tSettings("upgrade")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("compareHint")}</p>
</div>
{/* Desktop comparison table — badges in-flow; CTAs outside scroll clip */}
<div className="relative z-0 mb-2 hidden overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm lg:block">
<div className="overflow-x-auto overscroll-x-contain">
<div className="min-w-[720px] px-2 pb-2 pt-4">
<table className="w-full border-collapse text-center text-sm">
<thead>
<tr className="border-b border-border/80">
<th className="w-[28%] bg-muted/30 px-4 pb-4 pt-2 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("featureColumn")}
</th>
{PLAN_ORDER.map((plan) => {
const isCurrent = plan === normalizedCurrent;
const isPopular = plan === "Pro";
return (
<th
key={plan}
className={cn(
"px-3 pb-4 pt-2 align-top",
isPopular && "bg-[#E1F5EE]/60",
isCurrent && "ring-2 ring-inset ring-[#0F6E56]/40"
)}
>
<div className="flex flex-col items-center gap-2">
<div className="flex min-h-[1.375rem] flex-wrap items-center justify-center gap-1.5">
{isPopular ? (
<Badge className="whitespace-nowrap border-[#0F6E56]/30 bg-[#0F6E56] px-2.5 py-0.5 text-[10px] text-white hover:bg-[#0F6E56]">
{t("popular")}
</Badge>
) : null}
{isCurrent ? (
<Badge
variant="outline"
className="whitespace-nowrap border-[#0F6E56]/30 bg-white px-2.5 py-0.5 text-[10px] text-[#0F6E56]"
>
{t("current")}
</Badge>
) : null}
</div>
<div className="text-base font-semibold text-foreground">
{t(`names.${plan}`)}
</div>
<p className="font-medium text-[#0F6E56]">
{PRICES[plan] === null
? t("customPrice")
: PRICES[plan] === 0
? t("freePrice")
: formatCurrency(PRICES[plan]!, numberLocale)}
</p>
<p className="text-[11px] text-muted-foreground">{t("perMonth")}</p>
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{FEATURE_MATRIX.map((row, idx) => (
<tr
key={row.key}
className={cn(
"border-b border-border/60",
idx % 2 === 0 ? "bg-background" : "bg-muted/20"
)}
>
<td className="px-4 py-3 text-start text-sm text-foreground">
{t(`features.${row.key}`)}
</td>
{PLAN_ORDER.map((plan) => (
<td
key={plan}
className={cn(
"px-3 py-3",
plan === "Pro" && "bg-[#E1F5EE]/30",
plan === normalizedCurrent && "bg-[#E1F5EE]/50"
)}
>
<CellDisplay
cell={row.cells[plan]}
t={t}
numberLocale={numberLocale}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="overflow-x-auto overscroll-x-contain border-t border-border/80 bg-muted/10">
<div className="grid min-w-[720px] grid-cols-[28%_repeat(4,minmax(0,1fr))] items-center gap-0 px-2 py-5">
<div className="px-4" aria-hidden />
{PLAN_ORDER.map((plan) => (
<div
key={plan}
className={cn(
"px-3",
plan === "Pro" && "bg-[#E1F5EE]/30",
plan === normalizedCurrent && "bg-[#E1F5EE]/50"
)}
>
<PlanCta
plan={plan}
currentPlan={normalizedCurrent}
onSubscribe={onSubscribe}
isSubscribing={isSubscribing}
t={t}
fullWidth
/>
</div>
))}
</div>
</div>
</div>
{/* Mobile plan cards */}
<div className="grid gap-4 lg:hidden">
{PLAN_ORDER.map((plan) => {
const isCurrent = plan === normalizedCurrent;
const isPopular = plan === "Pro";
return (
<article
key={plan}
className={cn(
"relative rounded-xl border bg-card p-4 shadow-sm",
isPopular ? "border-[#0F6E56] ring-1 ring-[#0F6E56]/30" : "border-border/80",
isCurrent && "ring-2 ring-[#0F6E56]/50"
)}
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<h4 className="text-base font-semibold">{t(`names.${plan}`)}</h4>
{isPopular && (
<Badge className="bg-[#0F6E56] text-white hover:bg-[#0F6E56]">
{t("popular")}
</Badge>
)}
{isCurrent && (
<Badge variant="outline" className="border-[#0F6E56]/30 text-[#0F6E56]">
{t("current")}
</Badge>
)}
</div>
<p className="mb-4 text-lg font-medium text-[#0F6E56]">
{PRICES[plan] === null
? t("customPrice")
: PRICES[plan] === 0
? t("freePrice")
: formatCurrency(PRICES[plan]!, numberLocale)}
{PRICES[plan] !== null && PRICES[plan]! > 0 && (
<span className="ms-1 text-xs font-normal text-muted-foreground">
{t("perMonth")}
</span>
)}
</p>
<ul className="mb-4 space-y-2 border-t border-border/60 pt-3">
{FEATURE_MATRIX.map((row) => (
<li
key={row.key}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-muted-foreground">{t(`features.${row.key}`)}</span>
<CellDisplay
cell={row.cells[plan]}
t={t}
numberLocale={numberLocale}
/>
</li>
))}
</ul>
<PlanCta
plan={plan}
currentPlan={normalizedCurrent}
onSubscribe={onSubscribe}
isSubscribing={isSubscribing}
t={t}
fullWidth
/>
</article>
);
})}
</div>
</section>
);
}
function PlanCta({
plan,
currentPlan,
onSubscribe,
isSubscribing,
t,
fullWidth,
}: {
plan: PlanId;
currentPlan: PlanId;
onSubscribe: (planTier: "Pro" | "Business") => void;
isSubscribing: boolean;
t: ReturnType<typeof useTranslations<"settings.plans">>;
fullWidth?: boolean;
}) {
const isCurrent = plan === currentPlan;
if (plan === "Free") {
return (
<Button variant="outline" disabled className={fullWidth ? "w-full" : ""} size="sm">
{isCurrent ? t("currentPlanBtn") : t("included")}
</Button>
);
}
if (plan === "Enterprise") {
return (
<Button variant="outline" className={fullWidth ? "w-full" : ""} size="sm" asChild>
<a href="mailto:sales@meezi.ir">{t("contactSales")}</a>
</Button>
);
}
if (isCurrent) {
return (
<Button variant="secondary" disabled className={fullWidth ? "w-full" : ""} size="sm">
{t("currentPlanBtn")}
</Button>
);
}
return (
<Button
className={cn(
"bg-[#0F6E56] hover:bg-[#0c5a46]",
fullWidth ? "w-full" : ""
)}
size="sm"
disabled={isSubscribing}
onClick={() => onSubscribe(plan)}
>
{t("subscribe", { plan: t(`names.${plan}`) })}
</Button>
);
}
@@ -0,0 +1,439 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import {
applyCafeTheme,
CAFE_MENU_STYLES,
CAFE_MENU_TEXTURES,
CAFE_PANEL_STYLES,
CAFE_THEME_DENSITIES,
CAFE_THEME_PALETTES,
CAFE_THEME_RADIUS,
DEFAULT_CAFE_THEME,
normalizeCafeTheme,
resolveThemeColors,
COLOR_OPACITY_KEYS,
type CafeTheme,
type CafeThemeColorKey,
type CafeThemeCustomColors,
} from "@/lib/cafe-theme";
import { apiPatch } from "@/lib/api/client";
import { cafeSettingsQueryKey, useCafeSettings, type CafeSettings } from "@/lib/hooks/use-cafe-settings";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
import { GuestMenuTemplatePreview } from "@/components/settings/guest-menu-template-preview";
type SettingsAppearancePanelProps = {
cafeId: string;
};
const CUSTOM_COLOR_KEYS: CafeThemeColorKey[] = [
"primary",
"secondary",
"accent",
"background",
"surface",
"text",
"textMuted",
"destructive",
"success",
];
export function SettingsAppearancePanel({ cafeId }: SettingsAppearancePanelProps) {
const t = useTranslations("settings.appearance");
const tCommon = useTranslations("common");
const queryClient = useQueryClient();
const { data: cafeSettings } = useCafeSettings(cafeId);
const [theme, setTheme] = useState<CafeTheme>(DEFAULT_CAFE_THEME);
useEffect(() => {
if (!cafeSettings?.theme) return;
setTheme(normalizeCafeTheme(cafeSettings.theme));
}, [cafeSettings?.theme]);
const previewColors = useMemo(() => resolveThemeColors(theme), [theme]);
useEffect(() => {
applyCafeTheme(theme);
}, [theme]);
const setCustom = (key: CafeThemeColorKey, value: string) => {
setTheme((prev) => ({
...prev,
custom: { ...(prev.custom ?? {}), [key]: value || null },
}));
};
const setCustomOpacity = (key: CafeThemeColorKey, value: number) => {
const opacityKey = COLOR_OPACITY_KEYS[key];
setTheme((prev) => ({
...prev,
custom: { ...(prev.custom ?? {}), [opacityKey]: value },
}));
};
const clearCustom = () => {
setTheme((prev) => ({ ...prev, custom: null }));
};
const save = useMutation({
mutationFn: () =>
apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, { theme }),
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success(t("saved"));
},
});
return (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("paletteSection")}
</p>
<CardTitle className="text-base">{t("paletteTitle")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("paletteHint")}</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{CAFE_THEME_PALETTES.map((palette) => {
const selected = theme.paletteId === palette.id;
return (
<button
key={palette.id}
type="button"
onClick={() => setTheme((p) => ({ ...p, paletteId: palette.id }))}
className={cn(
"flex items-center gap-2 rounded-lg border p-2 text-start transition-all active:scale-[0.98]",
selected
? "border-primary bg-accent ring-1 ring-primary/30"
: "border-border/80 hover:border-primary/40"
)}
>
<span
className="h-8 w-8 shrink-0 rounded-md border border-black/10 shadow-inner"
style={{
background: `linear-gradient(135deg, ${palette.primary} 50%, ${palette.secondary} 50%)`,
}}
/>
<span className="min-w-0 truncate text-xs font-medium">
{t(`palettes.${palette.id}`)}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("dashboardSection")}
</p>
<CardTitle className="text-base">{t("dashboardTitle")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("dashboardDesc")}</p>
</CardHeader>
<CardContent className="space-y-4">
<StyleChipRow
label={t("panelStyle")}
options={CAFE_PANEL_STYLES}
value={theme.panelStyle}
labelPrefix="panelStyles"
onChange={(panelStyle) => setTheme((p) => ({ ...p, panelStyle }))}
/>
<StyleChipRow
label={t("density")}
options={CAFE_THEME_DENSITIES}
value={theme.density}
labelPrefix="densities"
onChange={(density) => setTheme((p) => ({ ...p, density }))}
/>
<StyleChipRow
label={t("radius")}
options={CAFE_THEME_RADIUS}
value={theme.radius}
labelPrefix="radiusOptions"
onChange={(radius) => setTheme((p) => ({ ...p, radius }))}
/>
<div className="border-t border-border/60 pt-4">
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("dashboardPreviewSection")}
</p>
<p className="mb-3 text-sm font-medium text-foreground">{t("dashboardPreviewTitle")}</p>
<div
className="rounded-xl border p-4 transition-colors"
style={{ background: previewColors.background }}
data-panel-style={theme.panelStyle}
>
<div className="flex gap-3">
<div
className="theme-preview-sidebar w-24 shrink-0 rounded-lg p-2 shadow-sm"
style={{ background: previewColors.surface }}
>
<div
className="mb-2 h-6 rounded-md px-2 text-[10px] font-medium leading-6 text-white"
style={{ background: previewColors.primary }}
>
{t("previewNav")}
</div>
<div
className="h-5 rounded-md opacity-80"
style={{ background: previewColors.secondary }}
/>
<div
className="mt-1.5 h-5 rounded-md opacity-60"
style={{ background: previewColors.secondary }}
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div
className="theme-preview-menu-card rounded-lg border p-3 shadow-sm"
style={{
background: previewColors.surface,
borderColor: `${previewColors.primary}33`,
}}
>
<p className="text-sm font-medium" style={{ color: previewColors.text }}>
{t("previewItem")}
</p>
<p className="text-xs" style={{ color: previewColors.textMuted }}>
۱۲۰٬۰۰۰ ت
</p>
<span
className="mt-2 inline-block rounded-md px-2 py-0.5 text-[10px] text-white"
style={{ background: previewColors.primary }}
>
{t("previewCta")}
</span>
</div>
</div>
</div>
</div>
<p className="mt-2 text-[11px] text-muted-foreground">{t("dashboardPreviewHint")}</p>
</div>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("guestMenuSection")}
</p>
<CardTitle className="text-base">{t("guestMenuTitle")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("guestMenuDesc")}</p>
</CardHeader>
<CardContent className="space-y-4">
<StyleChipRow
label={t("guestMenuStyle")}
options={CAFE_MENU_STYLES}
value={theme.menuStyle}
labelPrefix="menuStyles"
onChange={(menuStyle) => setTheme((p) => ({ ...p, menuStyle }))}
/>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t("menuTexture")}</p>
<TextureSwatchGrid
textures={CAFE_MENU_TEXTURES}
value={theme.menuTexture}
backgroundColor={previewColors.background}
onChange={(menuTexture) => setTheme((p) => ({ ...p, menuTexture }))}
labelPrefix="menuTextures"
/>
</div>
<div className="border-t border-border/60 pt-4">
<p className="mb-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("guestMenuPreviewSection")}
</p>
<GuestMenuTemplatePreview
theme={theme}
cafeName={cafeSettings?.name ?? undefined}
/>
</div>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("customSection")}
</p>
<CardTitle className="text-base">{t("customTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">{t("customHint")}</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{CUSTOM_COLOR_KEYS.map((key) => {
const base = resolveThemeColors({ ...theme, custom: null });
const value = theme.custom?.[key] ?? paletteColorForKey(base, key);
const opacityKey = COLOR_OPACITY_KEYS[key];
const opacity =
(theme.custom?.[opacityKey] as number | null | undefined) ?? 100;
return (
<LabeledField key={key} label={t(`colors.${key}`)}>
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={value.startsWith("#") ? value : "#0F6E56"}
onChange={(e) => setCustom(key, e.target.value)}
className="h-9 w-12 cursor-pointer rounded border border-border/80 bg-card"
/>
<input
type="text"
value={theme.custom?.[key] ?? ""}
placeholder={value}
onChange={(e) => setCustom(key, e.target.value)}
className="flex-1 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
dir="ltr"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{t("colorOpacity")}</span>
<input
type="range"
min={0}
max={100}
value={opacity}
onChange={(e) => setCustomOpacity(key, Number(e.target.value))}
className="flex-1"
/>
<span className="w-8 text-end font-mono text-[11px] text-muted-foreground" dir="ltr">
{opacity}%
</span>
</div>
</div>
</LabeledField>
);
})}
</div>
<Button type="button" size="sm" variant="ghost" onClick={clearCustom}>
{t("resetCustom")}
</Button>
</CardContent>
</Card>
<Button
className="bg-primary text-primary-foreground hover:opacity-90"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{tCommon("save")}
</Button>
</div>
);
}
function paletteColorForKey(
palette: ReturnType<typeof resolveThemeColors>,
key: CafeThemeColorKey
): string {
switch (key) {
case "primary":
return palette.primary;
case "secondary":
return palette.secondary;
case "accent":
return palette.accent;
case "background":
return palette.background;
case "surface":
return palette.surface;
case "text":
return palette.text;
case "textMuted":
return palette.textMuted;
case "destructive":
return palette.destructive;
case "success":
return palette.success;
default:
return palette.primary;
}
}
function TextureSwatchGrid<T extends string>({
textures,
value,
backgroundColor,
onChange,
labelPrefix,
}: {
textures: readonly T[];
value: T;
backgroundColor: string;
onChange: (v: T) => void;
labelPrefix: string;
}) {
const t = useTranslations("settings.appearance");
return (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-8">
{textures.map((tex) => {
const selected = value === tex;
return (
<button
key={tex}
type="button"
title={t(`${labelPrefix}.${tex}` as any)}
onClick={() => onChange(tex)}
className={cn(
"qr-texture-swatch transition-all active:scale-[0.98]",
selected ? "ring-2 ring-primary ring-offset-1" : "opacity-90 hover:opacity-100"
)}
data-qr-texture={tex}
style={{ ["--qr-bg" as string]: backgroundColor }}
>
<span className="sr-only">{t(`${labelPrefix}.${tex}` as any)}</span>
</button>
);
})}
</div>
);
}
function StyleChipRow<T extends string>({
label,
options,
value,
labelPrefix,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
labelPrefix: string;
onChange: (v: T) => void;
}) {
const t = useTranslations("settings.appearance");
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => (
<button
key={opt}
type="button"
onClick={() => onChange(opt)}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
value === opt
? "border-primary bg-accent text-primary"
: "border-border/80 text-muted-foreground hover:border-primary/40"
)}
>
{t(`${labelPrefix}.${opt}` as any)}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import {
SETTINGS_NAV,
type SettingsGroupId,
type SettingsLeafId,
groupForLeaf,
} from "@/components/settings/settings-types";
type SettingsNavProps = {
activeLeaf: SettingsLeafId;
expandedGroup: SettingsGroupId;
onSelectLeaf: (leaf: SettingsLeafId) => void;
onToggleGroup: (group: SettingsGroupId) => void;
};
export function SettingsNav({
activeLeaf,
expandedGroup,
onSelectLeaf,
onToggleGroup,
}: SettingsNavProps) {
const t = useTranslations("settings");
return (
<nav
className="shrink-0 rounded-xl border border-border/80 bg-card p-3 shadow-sm md:w-52 lg:w-56"
aria-label={t("nav.aria")}
>
<ul className="space-y-1.5">
{SETTINGS_NAV.map((group) => {
const isExpanded = expandedGroup === group.id;
const groupActive = groupForLeaf(activeLeaf) === group.id;
return (
<li key={group.id}>
<button
type="button"
onClick={() => onToggleGroup(group.id)}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-start text-sm font-medium transition",
groupActive
? "bg-[#E1F5EE] text-[#0F6E56]"
: "text-foreground hover:bg-muted/60"
)}
>
<span className="min-w-0 flex-1 leading-snug">{t(group.labelKey)}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-180",
groupActive && "text-[#0F6E56]"
)}
aria-hidden
/>
</button>
{isExpanded ? (
<ul className="me-1 ms-3 mt-1.5 space-y-1 border-s-2 border-[#0F6E56]/25 ps-4">
{group.children.map((child) => {
const isActive = activeLeaf === child.id;
return (
<li key={child.id}>
<button
type="button"
onClick={() => onSelectLeaf(child.id)}
className={cn(
"w-full rounded-lg px-3 py-2 text-start text-[13px] leading-snug transition",
isActive
? "bg-[#0F6E56] text-white shadow-sm"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t(child.labelKey)}
</button>
</li>
);
})}
</ul>
) : null}
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Printer } from "lucide-react";
import { apiGet } from "@/lib/api/client";
import { printErrorMessage, testPrinter } from "@/lib/api/print";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type BranchPrintSettings = {
receiptPrinterIp?: string | null;
receiptPrinterPort?: number | null;
kitchenPrinterIp?: string | null;
kitchenPrinterPort?: number | null;
};
type SettingsPrintTestPanelProps = {
cafeId: string;
onOpenPrinterSettings?: () => void;
};
function printerEndpointLabel(
ip: string | null | undefined,
port: number | null | undefined
): string {
if (!ip?.trim()) return "—";
return `${ip.trim()}:${port ?? 9100}`;
}
export function SettingsPrintTestPanel({
cafeId,
onOpenPrinterSettings,
}: SettingsPrintTestPanelProps) {
const t = useTranslations("print");
const tSettings = useTranslations("settings");
const tCommon = useTranslations("common");
const [message, setMessage] = useState<string | null>(null);
const [lastTarget, setLastTarget] = useState<"receipt" | "kitchen" | null>(null);
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, isLoading: settingsLoading } = useQuery({
queryKey: ["branch-print-settings", cafeId, branchId],
queryFn: () =>
apiGet<BranchPrintSettings>(
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`
),
enabled: !!cafeId && !!branchId,
});
const runTest = useMutation({
mutationFn: (target: "receipt" | "kitchen") => {
const ip =
target === "receipt"
? settings?.receiptPrinterIp?.trim()
: settings?.kitchenPrinterIp?.trim();
const port =
target === "receipt"
? settings?.receiptPrinterPort ?? 9100
: settings?.kitchenPrinterPort ?? 9100;
if (!ip) throw new Error("PRINTER_NOT_CONFIGURED");
return testPrinter(cafeId, ip, port);
},
onMutate: (target) => setLastTarget(target),
onSuccess: () => setMessage(t("success")),
onError: (err) => setMessage(printErrorMessage(err, t)),
});
const isLoading = branchesLoading || settingsLoading;
const receiptReady = !!settings?.receiptPrinterIp?.trim();
const kitchenReady = !!settings?.kitchenPrinterIp?.trim();
if (isLoading) {
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 (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="space-y-2 px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{tSettings("nav.printTest")}</CardTitle>
<p className="text-sm leading-relaxed text-muted-foreground">{t("testPageHint")}</p>
</CardHeader>
<CardContent className="space-y-5 px-6 pb-6 pt-0">
{message ? (
<p
className={cn(
"rounded-md border px-3 py-2 text-sm",
lastTarget && runTest.isSuccess
? "border-[#0F6E56]/30 bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 bg-muted/40"
)}
>
{message}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-border/80 bg-card p-5 space-y-4">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#E1F5EE] text-[#0F6E56]">
<Printer className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-medium">{t("receiptPrinter")}</p>
<p className="text-[11px] text-muted-foreground" dir="ltr">
{printerEndpointLabel(
settings?.receiptPrinterIp,
settings?.receiptPrinterPort
)}
</p>
</div>
</div>
<Button
className="w-full bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={!receiptReady || runTest.isPending}
onClick={() => runTest.mutate("receipt")}
>
{t("testPrintReceipt")}
</Button>
{!receiptReady ? (
<p className="text-[11px] text-[#BA7517]">{t("notConfigured")}</p>
) : null}
</div>
<div className="rounded-xl border border-border/80 bg-card p-5 space-y-4">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-[#0C447C]">
<Printer className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-medium">{t("kitchenPrinter")}</p>
<p className="text-[11px] text-muted-foreground" dir="ltr">
{printerEndpointLabel(
settings?.kitchenPrinterIp,
settings?.kitchenPrinterPort
)}
</p>
</div>
</div>
<Button
className="w-full"
variant="outline"
disabled={!kitchenReady || runTest.isPending}
onClick={() => runTest.mutate("kitchen")}
>
{t("testPrintKitchen")}
</Button>
{!kitchenReady ? (
<p className="text-[11px] text-[#BA7517]">{t("notConfigured")}</p>
) : null}
</div>
</div>
{onOpenPrinterSettings ? (
<Button variant="ghost" size="sm" onClick={onOpenPrinterSettings}>
{t("configurePrinters")}
</Button>
) : null}
</CardContent>
</Card>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/lib/stores/auth.store";
import { PageHeader } from "@/components/layout/page-header";
import { SettingsNav } from "@/components/settings/settings-nav";
import { SettingsAppearancePanel } from "@/components/settings/settings-appearance-panel";
import { CafeDiscoverProfilePanel } from "@/components/discover/cafe-discover-profile-panel";
import { CafePublicProfilePanel } from "@/components/discover/cafe-public-profile-panel";
import { SettingsShopPanel } from "@/components/settings/settings-shop-panel";
import { SettingsTerminalsPanel } from "@/components/settings/settings-terminals-panel";
import { SettingsPrinterPanel } from "@/components/settings/settings-printer-panel";
import { SettingsPrintTestPanel } from "@/components/settings/settings-print-test-panel";
import {
DEFAULT_SETTINGS_LEAF,
groupForLeaf,
type SettingsGroupId,
type SettingsLeafId,
} from "@/components/settings/settings-types";
const LEAF_PAGE_TITLE: Record<SettingsLeafId, string> = {
"shop-general": "nav.shopGeneral",
"shop-appearance": "nav.shopAppearance",
"shop-discover": "nav.shopDiscover",
"printer-config": "nav.printerSettings",
"print-test": "nav.printTest",
};
export function SettingsScreen() {
const t = useTranslations("settings");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const [activeLeaf, setActiveLeaf] = useState<SettingsLeafId>(DEFAULT_SETTINGS_LEAF);
const [expandedGroup, setExpandedGroup] = useState<SettingsGroupId>("shop");
const selectLeaf = (leaf: SettingsLeafId) => {
setActiveLeaf(leaf);
setExpandedGroup(groupForLeaf(leaf));
};
const toggleGroup = (group: SettingsGroupId) => {
setExpandedGroup((prev) => (prev === group ? prev : group));
const firstChild = group === "shop" ? "shop-general" : "printer-config";
if (groupForLeaf(activeLeaf) !== group) {
selectLeaf(firstChild);
}
};
if (!cafeId) return null;
const pageTitle = t(LEAF_PAGE_TITLE[activeLeaf]);
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex flex-col gap-6 md:flex-row md:items-start md:gap-8">
<SettingsNav
activeLeaf={activeLeaf}
expandedGroup={expandedGroup}
onSelectLeaf={selectLeaf}
onToggleGroup={toggleGroup}
/>
<div className="min-w-0 flex-1 space-y-4">
<p className="pb-0.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{pageTitle}
</p>
{activeLeaf === "shop-general" ? (
<div className="space-y-4">
<SettingsShopPanel cafeId={cafeId} />
<SettingsTerminalsPanel />
</div>
) : null}
{activeLeaf === "shop-appearance" ? (
<SettingsAppearancePanel cafeId={cafeId} />
) : null}
{activeLeaf === "shop-discover" ? (
<div className="space-y-6">
<CafeDiscoverProfilePanel cafeId={cafeId} mode="merchant" />
<CafePublicProfilePanel cafeId={cafeId} />
</div>
) : null}
{activeLeaf === "printer-config" ? (
<SettingsPrinterPanel
cafeId={cafeId}
onOpenPrintTest={() => selectLeaf("print-test")}
/>
) : null}
{activeLeaf === "print-test" ? (
<SettingsPrintTestPanel
cafeId={cafeId}
onOpenPrinterSettings={() => selectLeaf("printer-config")}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,208 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiPatch, apiPost, apiUpload, resolveMediaUrl } from "@/lib/api/client";
import {
cafeSettingsQueryKey,
useCafeSettings,
type CafeSettings,
} from "@/lib/hooks/use-cafe-settings";
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";
import { notify } from "@/lib/notify";
type SettingsShopPanelProps = {
cafeId: string;
};
export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
const t = useTranslations("settings");
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [city, setCity] = useState("");
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [coverImageUrl, setCoverImageUrl] = useState("");
const [snappfoodVendorId, setSnappfoodVendorId] = useState("");
const { data: cafeSettings } = useCafeSettings(cafeId);
useEffect(() => {
if (!cafeSettings) return;
setName(cafeSettings.name ?? "");
setCity(cafeSettings.city ?? "");
setPhone(cafeSettings.phone ?? "");
setAddress(cafeSettings.address ?? "");
setDescription(cafeSettings.description ?? "");
setLogoUrl(cafeSettings.logoUrl ?? "");
setCoverImageUrl(cafeSettings.coverImageUrl ?? "");
setSnappfoodVendorId(cafeSettings.snappfoodVendorId ?? "");
}, [cafeSettings]);
const saveProfile = useMutation({
mutationFn: () =>
apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
name,
city,
phone,
address,
description,
logoUrl: logoUrl || null,
coverImageUrl: coverImageUrl || null,
snappfoodVendorId,
}),
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success(t("profile.saved"));
},
});
const uploadLogo = useMutation({
mutationFn: (file: File) =>
apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/cafe-logo`, file),
onSuccess: (data) => setLogoUrl(data.url),
});
const uploadCover = useMutation({
mutationFn: (file: File) =>
apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/cafe-cover`, file),
onSuccess: (data) => setCoverImageUrl(data.url),
});
const submitTaraz = useMutation({
mutationFn: () =>
apiPost<{ trackingCode?: string; message?: string }>(
`/api/cafes/${cafeId}/tax/taraz/submit`
),
onSuccess: (data) => notify.success(data.message ?? t("tarazQueued")),
});
const logoSrc = resolveMediaUrl(logoUrl);
return (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("profile.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-6 pb-6 pt-0">
<div className="flex flex-wrap items-center gap-4">
{logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="" className="h-16 w-16 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted text-xs text-muted-foreground">
{t("profile.logo")}
</div>
)}
<div className="flex flex-wrap gap-2">
<label className="cursor-pointer rounded-md border px-3 py-2 text-sm hover:bg-muted">
{t("profile.uploadLogo")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadLogo.mutate(f);
}}
/>
</label>
<label className="cursor-pointer rounded-md border px-3 py-2 text-sm hover:bg-muted">
{t("profile.uploadCover")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadCover.mutate(f);
}}
/>
</label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("profile.name")} htmlFor="cafe-name">
<Input id="cafe-name" value={name} onChange={(e) => setName(e.target.value)} />
</LabeledField>
<LabeledField label={t("profile.city")} htmlFor="cafe-city">
<Input id="cafe-city" value={city} onChange={(e) => setCity(e.target.value)} />
</LabeledField>
<LabeledField label={t("profile.phone")} htmlFor="cafe-phone">
<Input
id="cafe-phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("profile.address")} htmlFor="cafe-address">
<Input
id="cafe-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</LabeledField>
</div>
<LabeledField label={t("profile.description")} htmlFor="cafe-description">
<textarea
id="cafe-description"
className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={saveProfile.isPending}
onClick={() => saveProfile.mutate()}
>
{t("saveProfile")}
</Button>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("snappfoodVendor")}</CardTitle>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0">
<LabeledField label={t("snappfoodVendor")} htmlFor="snappfood-vendor">
<Input
id="snappfood-vendor"
value={snappfoodVendorId}
onChange={(e) => setSnappfoodVendorId(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("taraz")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6 pt-0">
<p className="text-sm leading-relaxed text-muted-foreground">{t("tarazHint")}</p>
<Button
variant="outline"
disabled={submitTaraz.isPending}
onClick={() => submitTaraz.mutate()}
>
{t("tarazSubmit")}
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiDelete, apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { getOrCreateTerminalId } from "@/lib/terminal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { notify } from "@/lib/notify";
type TerminalsResponse = {
terminals: { terminalId: string }[];
max: number;
};
export function SettingsTerminalsPanel() {
const t = useTranslations("settings.terminals");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const qc = useQueryClient();
const thisDevice = getOrCreateTerminalId();
const { data, isLoading } = useQuery({
queryKey: ["terminals", cafeId],
queryFn: () => apiGet<TerminalsResponse>(`/api/cafes/${cafeId}/terminals`),
enabled: !!cafeId,
});
const revoke = useMutation({
mutationFn: (terminalId: string) =>
apiDelete(`/api/cafes/${cafeId}/terminals/${encodeURIComponent(terminalId)}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["terminals", cafeId] });
notify.success(t("revoked"));
},
});
const list = data?.terminals ?? [];
const max = data?.max ?? 1;
return (
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("title")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("hint", { max })}</p>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("thisDevice")}: <span className="font-mono">{thisDevice}</span>
</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="space-y-2">
{list.map((row) => (
<li
key={row.terminalId}
className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 text-sm"
>
<span className="font-mono text-xs">{row.terminalId}</span>
<Button
size="sm"
variant="outline"
disabled={revoke.isPending}
onClick={() => revoke.mutate(row.terminalId)}
>
{t("revoke")}
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,40 @@
export type SettingsGroupId = "shop" | "printer";
export type SettingsLeafId =
| "shop-general"
| "shop-appearance"
| "shop-discover"
| "printer-config"
| "print-test";
export type SettingsNavGroup = {
id: SettingsGroupId;
labelKey: string;
children: { id: SettingsLeafId; labelKey: string }[];
};
export const SETTINGS_NAV: SettingsNavGroup[] = [
{
id: "shop",
labelKey: "nav.shop",
children: [
{ id: "shop-general", labelKey: "nav.shopGeneral" },
{ id: "shop-appearance", labelKey: "nav.shopAppearance" },
{ id: "shop-discover", labelKey: "nav.shopDiscover" },
],
},
{
id: "printer",
labelKey: "nav.printer",
children: [
{ id: "printer-config", labelKey: "nav.printerSettings" },
{ id: "print-test", labelKey: "nav.printTest" },
],
},
];
export const DEFAULT_SETTINGS_LEAF: SettingsLeafId = "shop-general";
export function groupForLeaf(leaf: SettingsLeafId): SettingsGroupId {
return leaf === "printer-config" || leaf === "print-test" ? "printer" : "shop";
}
@@ -0,0 +1,189 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { apiGet, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
type ShiftDto = {
id: string;
branchId: string;
openingCash: number;
closingCash?: number | null;
expectedCash: number;
discrepancy?: number | null;
status: string;
openedAt: string;
closedAt?: string | null;
};
export function ShiftsScreen() {
const t = useTranslations("shifts");
const tCommon = useTranslations("common");
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const setBranchId = useBranchStore((s) => s.setBranchId);
const qc = useQueryClient();
const [openCash, setOpenCash] = useState("0");
const [closeCash, setCloseCash] = useState("");
const [showClose, setShowClose] = useState(false);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<{ id: string; name: string }[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (!branchId && branches.length > 0) setBranchId(branches[0]!.id);
}, [branchId, branches, setBranchId]);
const { data: current, isLoading } = useQuery({
queryKey: ["shift-current", cafeId, branchId],
queryFn: async () => {
try {
return await apiGet<ShiftDto>(
`/api/cafes/${cafeId}/branches/${branchId}/shifts/current`
);
} catch {
return null;
}
},
enabled: !!cafeId && !!branchId,
retry: false,
});
const openShift = useMutation({
mutationFn: () =>
apiPost<ShiftDto>(`/api/cafes/${cafeId}/branches/${branchId}/shifts/open`, {
openingCash: Number(openCash) || 0,
}),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["shift-current", cafeId, branchId] });
notify.success(t("opened"));
},
});
const closeShift = useMutation({
mutationFn: () =>
apiPost<ShiftDto>(`/api/cafes/${cafeId}/branches/${branchId}/shifts/${current!.id}/close`, {
closingCash: Number(closeCash) || 0,
}),
onSuccess: () => {
setShowClose(false);
setCloseCash("");
void qc.invalidateQueries({ queryKey: ["shift-current", cafeId, branchId] });
notify.success(t("closed"));
},
});
if (!cafeId) return null;
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
{branches.length > 1 ? (
<LabeledField label={t("branch")}>
<select
className="flex h-10 max-w-xs rounded-md border border-input bg-background px-3 text-sm"
value={branchId ?? ""}
onChange={(e) => setBranchId(e.target.value || null)}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</LabeledField>
) : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : current?.status === "Open" ? (
<Card className="rounded-xl border border-[#0F6E56]/30">
<CardHeader>
<CardTitle className="text-base text-[#0F6E56]">{t("shiftOpen")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
{t("openingCash")}: {formatCurrency(current.openingCash, numberLocale)}
</p>
<p>
{t("expectedCash")}: {formatCurrency(current.expectedCash, numberLocale)}
</p>
{!showClose ? (
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
onClick={() => {
setCloseCash(String(Math.round(current.expectedCash)));
setShowClose(true);
}}
>
{t("closeShift")}
</Button>
) : (
<div className="space-y-2 rounded-lg border border-border p-3">
<LabeledField label={t("countedCash")}>
<Input
value={closeCash}
onChange={(e) => setCloseCash(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<div className="flex gap-2">
<Button
className="bg-[#0F6E56]"
disabled={closeShift.isPending}
onClick={() => closeShift.mutate()}
>
{t("confirmClose")}
</Button>
<Button variant="outline" onClick={() => setShowClose(false)}>
{tCommon("cancel")}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
) : (
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("openShift")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<LabeledField label={t("openingCash")}>
<Input
value={openCash}
onChange={(e) => setOpenCash(e.target.value)}
dir="ltr"
className="w-40 text-end"
/>
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!branchId || openShift.isPending}
onClick={() => openShift.mutate()}
>
{t("startShift")}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPost } from "@/lib/api/client";
import type { CustomerGroup, SmsCampaignResult, SmsUsage } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"];
export function SmsScreen() {
const t = useTranslations("sms");
const tCrm = useTranslations("crm");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [message, setMessage] = useState("");
const [target, setTarget] = useState<CustomerGroup | "all">("all");
const [result, setResult] = useState<SmsCampaignResult | null>(null);
const { data: usage } = useQuery({
queryKey: ["sms-usage", cafeId],
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
enabled: !!cafeId,
});
const sendCampaign = useMutation({
mutationFn: () =>
apiPost<SmsCampaignResult>(`/api/cafes/${cafeId}/sms/campaign`, {
message,
targetGroup: target === "all" ? null : target,
}),
onSuccess: (data) => {
setResult(data);
setMessage("");
queryClient.invalidateQueries({ queryKey: ["sms-usage", cafeId] });
},
});
if (!cafeId) return null;
const usageLabel =
usage?.monthlyLimit === -1
? t("unlimited")
: `${formatNumber(usage?.usedThisMonth ?? 0)} / ${formatNumber(usage?.monthlyLimit ?? 0)}`;
return (
<div className="space-y-4">
<h2 className="text-xl font-bold">{t("title")}</h2>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("usage")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-primary">{usageLabel}</p>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-4 pt-6">
<LabeledField label={t("targetGroup")} htmlFor="sms-target">
<select
id="sms-target"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={target}
onChange={(e) => setTarget(e.target.value as CustomerGroup | "all")}
>
{GROUPS.map((g) => (
<option key={g} value={g}>
{g === "all" ? t("allCustomers") : tCrm(`groups.${g}`)}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("message")} htmlFor="sms-message">
<textarea
id="sms-message"
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</LabeledField>
<Button
className="w-full"
disabled={!message.trim() || sendCampaign.isPending}
onClick={() => sendCampaign.mutate()}
>
{t("send")}
</Button>
{result && (
<p className="text-center text-sm text-muted-foreground">
{t("sent")}: {formatNumber(result.sentCount)} {t("failed")}:{" "}
{formatNumber(result.failedCount)}
</p>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/layout/page-header";
import { PlanComparison } from "@/components/settings/plan-comparison";
import type { AuthTokenResponse } from "@/lib/api/types";
import { Alert } from "@/components/ui/alert";
import { notify } from "@/lib/notify";
type BillingStatus = {
planTier: string;
planExpiresAt: string | null;
ordersToday: number;
ordersDailyLimit: number | null;
customersCount: number;
customersLimit: number | null;
smsUsedThisMonth: number;
smsMonthlyLimit: number;
menu3dEnabled: boolean;
discoverProfileEnabled: boolean;
isPlanExpired: boolean;
};
type SubscribeResponse = {
paymentId: string;
paymentUrl: string;
};
type PaymentMethod = {
id: string;
displayNameFa: string;
isDefault: boolean;
};
export function SubscriptionScreen() {
const t = useTranslations("subscription");
const tSettings = useTranslations("settings");
const searchParams = useSearchParams();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth);
const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [paymentMethod, setPaymentMethod] = useState("");
useEffect(() => {
const billing = searchParams.get("billing");
if (billing === "success") {
notify.success(t("paymentSuccess"));
setBillingBanner("success");
}
if (billing === "failed") {
notify.error(t("paymentFailed"));
setBillingBanner("failed");
}
}, [searchParams, t]);
const { data: status, isLoading, refetch } = useQuery({
queryKey: ["billing-status", cafeId],
queryFn: () => apiGet<BillingStatus>("/api/billing/status"),
enabled: !!cafeId,
});
const { data: paymentMethods = [] } = useQuery({
queryKey: ["billing-payment-methods", cafeId],
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
enabled: !!cafeId && isCafeOwner(role),
});
useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
setPaymentMethod(def.id);
}
}, [paymentMethods, paymentMethod]);
useEffect(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token");
if (!refresh) return;
billingRefreshed.current = true;
apiPost<AuthTokenResponse>("/api/auth/refresh", { refreshToken: refresh })
.then((tokens) => {
setAuth(tokens);
refetch();
})
.catch(() => notify.warning(tSettings("profile.reloginHint")));
}, [searchParams, setAuth, refetch, tSettings]);
const subscribe = useMutation({
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
onSuccess: (data) => {
window.location.href = data.paymentUrl;
},
});
if (!cafeId) return null;
if (!isCafeOwner(role)) {
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
</div>
);
}
const expiry = status?.planExpiresAt
? new Date(status.planExpiresAt).toLocaleDateString("fa-IR")
: t("noExpiry");
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
{billingBanner ? (
<Alert
variant={billingBanner === "failed" ? "destructive" : "success"}
onDismiss={() => setBillingBanner(null)}
>
{billingBanner === "failed" ? t("paymentFailed") : t("paymentSuccess")}
</Alert>
) : null}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader>
<CardTitle className="text-base">{t("currentPlan")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : status ? (
<>
<div className="flex flex-wrap items-center gap-2">
<Badge>{status.planTier}</Badge>
{status.isPlanExpired ? (
<Badge className="bg-[#A32D2D] text-white hover:bg-[#A32D2D]">
{t("planExpired")}
</Badge>
) : null}
<span className="text-sm text-muted-foreground">
{t("expires")}: {expiry}
</span>
<Button size="sm" variant="ghost" onClick={() => refetch()}>
{t("refresh")}
</Button>
</div>
<ul className="space-y-1 text-sm text-muted-foreground">
<li>
{t("ordersToday")}: {formatNumber(status.ordersToday)}
{status.ordersDailyLimit != null &&
` / ${formatNumber(status.ordersDailyLimit)}`}
</li>
<li>
{t("customers")}: {formatNumber(status.customersCount)}
{status.customersLimit != null &&
` / ${formatNumber(status.customersLimit)}`}
</li>
<li>
{t("smsUsage")}: {formatNumber(status.smsUsedThisMonth)}
{status.smsMonthlyLimit >= 0 &&
` / ${formatNumber(status.smsMonthlyLimit)}`}
</li>
<li>
{t("featureMenu3d")}:{" "}
{status.menu3dEnabled ? t("featureOn") : t("featureOff")}
</li>
<li>
{t("featureDiscover")}:{" "}
{status.discoverProfileEnabled ? t("featureOn") : t("featureOff")}
</li>
</ul>
</>
) : null}
</CardContent>
</Card>
{paymentMethods.length > 0 ? (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("paymentMethod")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{paymentMethods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setPaymentMethod(m.id)}
className={cn(
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
paymentMethod === m.id
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 hover:border-[#0F6E56]/40"
)}
>
{m.displayNameFa}
</button>
))}
</CardContent>
</Card>
) : null}
<PlanComparison
currentPlan={status?.planTier ?? "Free"}
onSubscribe={(planTier) =>
subscribe.mutate({
planTier,
months: 1,
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
})
}
isSubscribing={subscribe.isPending}
/>
</div>
);
}
@@ -0,0 +1,253 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { notify } from "@/lib/notify";
import {
isTicketClosed,
TicketStatusBadge,
type TicketStatus,
} from "@/components/support/ticket-status-badge";
type SupportTicket = {
id: string;
subject: string;
status: TicketStatus;
priority: string;
updatedAt: string;
messageCount: number;
};
type SupportTicketDetail = {
ticket: SupportTicket & { updatedAt: string };
messages: {
id: string;
senderKind: string;
senderName?: string | null;
body: string;
createdAt: string;
}[];
};
function formatDate(iso: string) {
try {
return new Date(iso).toLocaleString("fa-IR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
export function SupportScreen() {
const t = useTranslations("support");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const qc = useQueryClient();
const {
data: tickets = [],
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["support", cafeId],
queryFn: () => apiGet<SupportTicket[]>(`/api/cafes/${cafeId}/support/tickets`),
enabled: !!cafeId,
});
const create = useMutation({
mutationFn: () =>
apiPost<SupportTicketDetail>(`/api/cafes/${cafeId}/support/tickets`, {
subject,
body,
priority: "Normal",
}),
onSuccess: (detail) => {
setSubject("");
setBody("");
qc.setQueryData<SupportTicket[]>(["support", cafeId], (prev = []) => {
const row: SupportTicket = {
id: detail.ticket.id,
subject: detail.ticket.subject,
status: detail.ticket.status,
priority: detail.ticket.priority,
updatedAt: detail.ticket.updatedAt,
messageCount: detail.messages.length,
};
if (prev.some((x) => x.id === row.id)) return prev;
return [row, ...prev];
});
void qc.invalidateQueries({ queryKey: ["support", cafeId] });
notify.success(t("created"));
},
onError: () => notify.error(t("createFailed")),
});
if (!cafeId) return null;
return (
<div className="mx-auto max-w-3xl space-y-4">
<div>
<h1 className="text-lg font-medium">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("newTicket")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("subject")}
/>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={t("message")}
/>
<Button
disabled={!subject.trim() || !body.trim() || create.isPending}
onClick={() => create.mutate()}
>
{t("submit")}
</Button>
</CardContent>
</Card>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("myTickets")}
</p>
{isError ? (
<Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive">
<p>{t("loadFailed")}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}>
{t("retry")}
</Button>
</Card>
) : isLoading ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : tickets.length === 0 ? (
<Card className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">
{t("empty")}
</Card>
) : (
<div className="space-y-2">
{tickets.map((ticket) => (
<Link key={ticket.id} href={`/support/${ticket.id}`}>
<Card className="rounded-xl border p-4 transition hover:border-primary">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">{ticket.subject}</p>
<TicketStatusBadge status={ticket.status} />
</div>
<p className="mt-2 text-xs text-muted-foreground">
{ticket.messageCount} {t("messages")} · {formatDate(ticket.updatedAt)}
</p>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
export function SupportTicketDetailScreen() {
const t = useTranslations("support");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const params = useParams();
const ticketId = params.ticketId as string;
const [reply, setReply] = useState("");
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["support", cafeId, ticketId],
queryFn: () =>
apiGet<SupportTicketDetail>(`/api/cafes/${cafeId}/support/tickets/${ticketId}`),
enabled: !!cafeId && !!ticketId,
});
const closed = data ? isTicketClosed(data.ticket.status) : false;
const send = useMutation({
mutationFn: () =>
apiPost<SupportTicketDetail>(
`/api/cafes/${cafeId}/support/tickets/${ticketId}/messages`,
{ body: reply }
),
onSuccess: () => {
setReply("");
void qc.invalidateQueries({ queryKey: ["support", cafeId, ticketId] });
void qc.invalidateQueries({ queryKey: ["support", cafeId] });
notify.success(t("replySent"));
},
onError: () => notify.error(t("replyFailed")),
});
if (isLoading) return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
if (!data) return <p className="text-sm text-muted-foreground">{t("notFound")}</p>;
return (
<div className="mx-auto max-w-2xl space-y-4">
<Link href="/support" className="text-sm text-primary">
{t("back")}
</Link>
<Card className="rounded-xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<h1 className="text-lg font-medium">{data.ticket.subject}</h1>
<TicketStatusBadge status={data.ticket.status} />
</div>
{closed ? (
<p className="mt-2 text-sm text-muted-foreground">{t("closedHint")}</p>
) : null}
</Card>
<div className="space-y-2">
{data.messages.map((m) => (
<Card
key={m.id}
className={`rounded-xl border p-3 ${
m.senderKind === "Merchant"
? "border-primary/20 bg-[#E1F5EE]/30 ms-8"
: "border-border/80 me-8"
}`}
>
<p className="text-xs font-medium text-muted-foreground">
{m.senderKind === "Admin" ? t("fromAdmin") : t("fromYou")}
{m.senderName ? ` · ${m.senderName}` : ""}
</p>
<p className="mt-1 text-sm whitespace-pre-wrap">{m.body}</p>
<p className="mt-2 text-[10px] text-muted-foreground">{formatDate(m.createdAt)}</p>
</Card>
))}
</div>
{!closed ? (
<Card className="space-y-2 rounded-xl border p-4">
<textarea
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder={t("reply")}
/>
<Button disabled={!reply.trim() || send.isPending} onClick={() => send.mutate()}>
{t("send")}
</Button>
</Card>
) : null}
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export type TicketStatus =
| "Open"
| "InProgress"
| "WaitingMerchant"
| "Resolved"
| "Closed"
| string;
export function isTicketClosed(status: TicketStatus): boolean {
return status === "Closed" || status === "Resolved";
}
export function TicketStatusBadge({
status,
className,
}: {
status: TicketStatus;
className?: string;
}) {
const t = useTranslations("support.status");
const label = (() => {
switch (status) {
case "Open":
return t("open");
case "InProgress":
return t("inProgress");
case "WaitingMerchant":
return t("waitingMerchant");
case "Resolved":
return t("resolved");
case "Closed":
return t("closed");
default:
return status;
}
})();
const styles = (() => {
switch (status) {
case "Open":
return "bg-amber-100 text-amber-900 border-amber-200";
case "InProgress":
return "bg-blue-100 text-blue-900 border-blue-200";
case "WaitingMerchant":
return "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/20";
case "Resolved":
return "bg-muted text-muted-foreground";
case "Closed":
return "bg-muted text-muted-foreground";
default:
return "";
}
})();
return (
<Badge variant="outline" className={cn("border font-normal", styles, className)}>
{label}
</Badge>
);
}
@@ -0,0 +1,538 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
import { notify } from "@/lib/notify";
import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { PageHeader } from "@/components/layout/page-header";
import {
apiGet,
apiGetBlob,
apiPatch,
apiPost,
ApiClientError,
openBlobInNewTab,
resolveMediaUrl,
} from "@/lib/api/client";
import {
createBranchTable,
deleteBranchTable,
fetchCafeTableBoard,
patchBranchTable,
setTableCleaning,
} from "@/lib/api/branch-tables";
import { useBranchStore } from "@/lib/stores/branch.store";
import type { TableBoardItem } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatCurrency, formatNumber } from "@/lib/format";
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 { cn } from "@/lib/utils";
import { useConfirm } from "@/components/providers/confirm-provider";
import { Alert } from "@/components/ui/alert";
const statusStyles: Record<TableBoardItem["status"], string> = {
Free: "bg-[#E1F5EE] text-[#0F6E56] border-[#0F6E56]/30",
Busy: "bg-blue-50 text-[#0C447C] border-blue-200",
Reserved: "bg-amber-50 text-[#BA7517] border-amber-200",
Cleaning: "bg-slate-100 text-slate-600 border-slate-300",
};
export function TablesScreen() {
const t = useTranslations("tables");
const tCommon = useTranslations("common");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient();
const confirmDialog = useConfirm();
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [number, setNumber] = useState("");
const [capacity, setCapacity] = useState("4");
const [floor, setFloor] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editNumber, setEditNumber] = useState("");
const [editCapacity, setEditCapacity] = useState("");
const [editFloor, setEditFloor] = useState("");
const [editImageUrl, setEditImageUrl] = useState("");
const [editVideoUrl, setEditVideoUrl] = useState("");
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:5080";
const canManage = role === "Owner" || role === "Manager";
const { data: tables = [], isLoading } = useQuery({
queryKey: ["tables-board", cafeId, branchId, "manage"],
queryFn: () => fetchCafeTableBoard(cafeId!, branchId),
enabled: !!cafeId,
});
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["tables-board", cafeId] });
}, [queryClient, cafeId]);
useEffect(() => {
if (!cafeId) return;
const token =
typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null;
const connection = new signalR.HubConnectionBuilder()
.withUrl(`${apiBase}/hubs/kds`, { accessTokenFactory: () => token ?? "" })
.withAutomaticReconnect()
.build();
connection
.start()
.then(() => connection.invoke("JoinCafe", cafeId))
.catch(() => undefined);
connection.on("TableStatusChanged", () => refresh());
connection.on("OrderCreated", () => refresh());
connection.on("OrderStatusChanged", () => refresh());
return () => {
void connection.stop();
};
}, [cafeId, apiBase, refresh]);
const createTable = useMutation({
mutationFn: async () => {
const cap = parseInt(capacity, 10);
if (branchId) {
await createBranchTable(cafeId!, branchId, {
number,
capacity: cap,
floor: floor || null,
});
return;
}
await apiPost(`/api/cafes/${cafeId}/tables`, {
number,
capacity: cap,
floor: floor || null,
});
},
onSuccess: () => {
setShowForm(false);
setNumber("");
setFloor("");
setActionMessage(null);
refresh();
},
onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("createError"));
},
});
const setCleaning = useMutation({
mutationFn: ({
table,
isCleaning,
}: {
table: TableBoardItem;
isCleaning: boolean;
}) => setTableCleaning(cafeId!, table.id, isCleaning, branchId ?? table.branchId),
onSuccess: () => {
setActionMessage(null);
refresh();
},
onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("cleaningError"));
},
});
const deleteTable = useMutation({
mutationFn: (table: TableBoardItem) =>
deleteBranchTable(cafeId!, table.branchId, table.id),
onSuccess: () => {
setActionMessage(null);
refresh();
},
onError: (err) => {
if (err instanceof ApiClientError && err.code === "TABLE_HAS_OPEN_ORDER") {
setActionMessage(t("tableHasOpenOrder"));
return;
}
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError"));
},
});
const patchTable = useMutation({
mutationFn: ({
table,
body,
}: {
table: TableBoardItem;
body: {
number?: string;
capacity?: number;
floor?: string | null;
imageUrl?: string;
videoUrl?: string;
isActive?: boolean;
};
}) => {
if (branchId) {
return patchBranchTable(cafeId!, branchId, table.id, body);
}
return apiPatch(`/api/cafes/${cafeId}/tables/${table.id}`, body);
},
onSuccess: () => {
setEditingId(null);
setActionMessage(null);
refresh();
},
});
const startEdit = (table: TableBoardItem) => {
setEditingId(table.id);
setEditNumber(table.number);
setEditCapacity(String(table.capacity));
setEditFloor(table.floor ?? "");
setEditImageUrl(table.imageUrl ?? "");
setEditVideoUrl(table.videoUrl ?? "");
};
const mediaField = (url: string) => (url.trim() === "" ? "" : url);
const openQr = async (tableId: string) => {
try {
const blob = await apiGetBlob(`/api/cafes/${cafeId}/tables/${tableId}/qr`);
openBlobInNewTab(blob);
} catch {
/* ignore */
}
};
const copyQrUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
notify.success(t("qrUrlCopied"));
} catch {
notify.error(t("qrUrlCopyFailed"));
}
};
if (!cafeId) return null;
return (
<div className="space-y-6 bg-[#f5f5f4] min-h-full -m-4 p-4 md:-m-6 md:p-6">
<PageHeader
title={t("title")}
subtitle={t("floorPlan")}
action={
canManage ? (
<Button
className="bg-[#0F6E56] hover:bg-[#0d5e49]"
onClick={() => setShowForm((v) => !v)}
>
<Plus className="ms-2 h-4 w-4" />
{t("addTable")}
</Button>
) : undefined
}
/>
{showForm && (
<Card className="rounded-xl border border-border/80 bg-card">
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
<LabeledField label={t("number")} htmlFor="table-number">
<Input
id="table-number"
value={number}
onChange={(e) => setNumber(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("capacity")} htmlFor="table-capacity">
<Input
id="table-capacity"
type="number"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("floor")} htmlFor="table-floor">
<Input
id="table-floor"
value={floor}
onChange={(e) => setFloor(e.target.value)}
/>
</LabeledField>
<div className="flex gap-2 sm:col-span-3">
<Button
disabled={!number.trim() || createTable.isPending}
onClick={() => createTable.mutate()}
>
{tCommon("save")}
</Button>
<Button variant="outline" onClick={() => setShowForm(false)}>
{tCommon("cancel")}
</Button>
</div>
</CardContent>
</Card>
)}
{editingId && (
<Card className="rounded-xl border border-[#0F6E56]/30 bg-card">
<CardContent className="grid gap-3 pt-6 sm:grid-cols-3">
<p className="text-sm font-medium sm:col-span-3">{t("editTable")}</p>
<LabeledField label={t("number")} htmlFor="edit-table-number">
<Input
id="edit-table-number"
value={editNumber}
onChange={(e) => setEditNumber(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("capacity")} htmlFor="edit-table-capacity">
<Input
id="edit-table-capacity"
type="number"
value={editCapacity}
onChange={(e) => setEditCapacity(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("floor")} htmlFor="edit-table-floor">
<Input
id="edit-table-floor"
value={editFloor}
onChange={(e) => setEditFloor(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("media")} className="sm:col-span-3">
<MediaPairUpload
cafeId={cafeId}
kind="table"
imageUrl={editImageUrl}
videoUrl={editVideoUrl}
onImageChange={(url) => setEditImageUrl(url ?? "")}
onVideoChange={(url) => setEditVideoUrl(url ?? "")}
/>
</LabeledField>
<div className="flex gap-2 sm:col-span-3">
<Button
disabled={!editNumber.trim() || patchTable.isPending}
onClick={() => {
const editing = tables.find((tbl) => tbl.id === editingId);
if (!editing) return;
patchTable.mutate({
table: editing,
body: {
number: editNumber,
capacity: parseInt(editCapacity, 10),
floor: editFloor || null,
imageUrl: mediaField(editImageUrl),
videoUrl: mediaField(editVideoUrl),
},
});
}}
>
{t("saveTable")}
</Button>
<Button variant="outline" onClick={() => setEditingId(null)}>
{tCommon("cancel")}
</Button>
</div>
</CardContent>
</Card>
)}
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : tables.length === 0 ? (
<p className="text-sm text-muted-foreground">
{branchId ? t("emptyBranch") : t("empty")}
</p>
) : (
<>
{actionMessage ? (
<Alert variant="info" onDismiss={() => setActionMessage(null)}>
{actionMessage}
</Alert>
) : null}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tables.map((table) => (
<Card
key={table.id}
className={cn(
"rounded-xl border border-border/80 bg-card transition-colors hover:border-[#0F6E56]"
)}
>
<CardContent className="space-y-3 pt-6">
{(table.imageUrl || table.videoUrl) && (
<div className="relative aspect-[16/9] overflow-hidden rounded-lg bg-muted">
{resolveMediaUrl(table.imageUrl) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(table.imageUrl)!}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="h-full min-h-[80px] bg-muted/50" />
)}
{table.videoUrl ? (
<span className="absolute bottom-2 start-2 flex items-center gap-1 rounded-md bg-black/60 px-2 py-0.5 text-[10px] text-white">
<Video className="h-3 w-3" />
Video
</span>
) : null}
</div>
)}
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-base font-medium">
{t("tableLabel", { number: table.number })}
</p>
<p className="text-[11px] text-muted-foreground">
{t("meta", {
capacity: formatNumber(table.capacity),
floor: table.floor ?? "—",
})}
</p>
{!table.isActive ? (
<Badge
variant="outline"
className="mt-1 text-[10px] text-muted-foreground"
>
{t("inactive")}
</Badge>
) : null}
</div>
<Badge className={cn("border", statusStyles[table.status])}>
<span className="me-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
{t(`status.${table.status}`)}
</Badge>
</div>
{table.currentOrder && table.status === "Busy" && (
<p className="text-sm text-[#0F6E56]">
{table.currentOrder.guestLabel ?? t("activeOrder")} {" "}
{formatCurrency(table.currentOrder.total)}
</p>
)}
{table.currentOrder && table.status === "Reserved" && (
<p className="text-sm text-[#BA7517]">
{table.currentOrder.guestLabel} {t("reserved")}
</p>
)}
{table.qrCodeUrl ? (
<div className="rounded-lg border border-border/80 bg-muted/20 px-2.5 py-2">
<p className="text-[10px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("qrMenuUrl")}
</p>
<a
href={table.qrCodeUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 block break-all text-xs font-medium text-[#0F6E56] hover:underline"
dir="ltr"
>
{table.qrCodeUrl}
</a>
<div className="mt-2 flex flex-wrap gap-1.5">
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" asChild>
<a href={table.qrCodeUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
{t("openQrUrl")}
</a>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
type="button"
onClick={() => void copyQrUrl(table.qrCodeUrl)}
>
<Copy className="h-3 w-3" />
{t("copyQrUrl")}
</Button>
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{canManage ? (
<Button size="sm" variant="outline" onClick={() => startEdit(table)}>
<Pencil className="ms-1 h-3.5 w-3.5" />
{t("edit")}
</Button>
) : null}
<Button size="sm" variant="outline" onClick={() => openQr(table.id)}>
<QrCode className="ms-1 h-3.5 w-3.5" />
{t("printQr")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setCleaning.mutate({
table,
isCleaning: !(table.isCleaning ?? table.status === "Cleaning"),
})
}
>
{(table.isCleaning ?? table.status === "Cleaning")
? t("markReady")
: t("markCleaning")}
</Button>
{canManage ? (
<Button
size="sm"
variant="ghost"
className="text-[#A32D2D]"
disabled={deleteTable.isPending}
onClick={async () => {
const ok = await confirmDialog({
description: t("deleteTableConfirm"),
variant: "destructive",
confirmLabel: tCommon("delete"),
});
if (!ok) return;
deleteTable.mutate(table);
}}
>
<Trash2 className="ms-1 h-3.5 w-3.5" />
{t("deleteTable")}
</Button>
) : null}
{canManage && table.isActive ? (
<Button
size="sm"
variant="ghost"
className="text-[#A32D2D]"
onClick={() =>
patchTable.mutate({ table, body: { isActive: false } })
}
>
{t("deactivate")}
</Button>
) : canManage && !table.isActive ? (
<Button
size="sm"
variant="outline"
onClick={() =>
patchTable.mutate({ table, body: { isActive: true } })
}
>
{t("reactivate")}
</Button>
) : null}
</div>
<p className="text-[11px] text-muted-foreground">{t("reprintHint")}</p>
</CardContent>
</Card>
))}
</div>
</>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { applyCafeTheme, DEFAULT_CAFE_THEME, normalizeCafeTheme } from "@/lib/cafe-theme";
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
import { useAuthStore } from "@/lib/stores/auth.store";
export function CafeThemeProvider({ children }: { children: React.ReactNode }) {
const cafeId = useAuthStore((s) => s.user?.cafeId);
const { data } = useCafeSettings(cafeId);
useEffect(() => {
const theme = normalizeCafeTheme(data?.theme ?? DEFAULT_CAFE_THEME);
applyCafeTheme(theme);
}, [data?.theme]);
return children;
}
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl border border-border/80 bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-2 text-center sm:text-start", className)} {...props} />
);
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-base font-medium leading-snug text-foreground", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground leading-relaxed", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from "lucide-react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative flex w-full gap-3 rounded-xl border px-4 py-3 text-sm shadow-sm [&>svg]:shrink-0",
{
variants: {
variant: {
default: "border-border/80 bg-card text-foreground [&>svg]:text-muted-foreground",
info: "border-[#0C447C]/25 bg-[#0C447C]/5 text-[#0C447C] [&>svg]:text-[#0C447C]",
success:
"border-[#0F6E56]/25 bg-[#E1F5EE] text-[#0F6E56] [&>svg]:text-[#0F6E56]",
warning:
"border-[#BA7517]/30 bg-amber-50 text-[#BA7517] [&>svg]:text-[#BA7517]",
destructive:
"border-[#A32D2D]/25 bg-red-50 text-[#A32D2D] [&>svg]:text-[#A32D2D]",
},
},
defaultVariants: { variant: "default" },
}
);
const iconByVariant = {
default: Info,
info: Info,
success: CheckCircle2,
warning: TriangleAlert,
destructive: AlertCircle,
} as const;
export interface AlertProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof alertVariants> {
title?: string;
onDismiss?: () => void;
}
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
({ className, variant = "default", title, onDismiss, children, ...props }, ref) => {
const Icon = iconByVariant[variant ?? "default"];
return (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
>
<Icon className="mt-0.5 h-4 w-4" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
{title ? <p className="font-medium leading-snug">{title}</p> : null}
{children ? (
<div className={cn("text-[13px] leading-relaxed opacity-95", title && "opacity-90")}>
{children}
</div>
) : null}
</div>
{onDismiss ? (
<button
type="button"
onClick={onDismiss}
className="absolute end-2 top-2 rounded-md p-1 opacity-60 transition hover:bg-black/5 hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}
);
Alert.displayName = "Alert";
export { Alert, alertVariants };
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
@@ -0,0 +1,48 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:opacity-90",
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
);
const CardTitle = ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("p-6 pt-0", className)} {...props} />
);
export { Card, CardHeader, CardTitle, CardContent };
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-card p-1 text-foreground shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
};
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
);
Input.displayName = "Input";
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-[11px] font-medium leading-none text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,23 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
type LabeledFieldProps = {
label: string;
htmlFor?: string;
children: ReactNode;
className?: string;
hint?: string;
};
export function LabeledField({ label, htmlFor, children, className, hint }: LabeledFieldProps) {
return (
<div className={cn("space-y-2", className)}>
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{hint ? <p className="text-[10px] text-muted-foreground">{hint}</p> : null}
</div>
);
}
@@ -0,0 +1,116 @@
"use client";
import type { ReactNode } from "react";
import { useLocale } from "next-intl";
import { Toaster } from "sonner";
import {
AlertCircle,
CheckCircle2,
Info,
Loader2,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
function iconWrap(className: string, icon: ReactNode) {
return (
<span
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
className
)}
>
{icon}
</span>
);
}
export function MeeziToaster() {
const locale = useLocale();
const isRtl = locale !== "en";
const isEn = locale === "en";
const fontClass = isEn
? "font-[family-name:var(--font-inter)]"
: "font-[family-name:var(--font-vazirmatn)]";
const toastBase = cn(
"group relative flex w-[min(calc(100vw-2rem),400px)] items-start gap-3 overflow-hidden",
"rounded-xl border border-border/60 bg-card/95 py-3.5 ps-3.5 pe-10",
"shadow-[0_10px_40px_-8px_rgba(15,23,42,0.16)] backdrop-blur-md",
"transition-[transform,opacity] duration-200",
fontClass
);
const titleClass = "text-[13px] font-semibold leading-snug tracking-tight text-foreground";
const descriptionClass = "text-xs leading-relaxed text-muted-foreground";
return (
<Toaster
dir={isRtl ? "rtl" : "ltr"}
position={isRtl ? "top-left" : "top-right"}
closeButton
richColors={false}
expand
gap={12}
offset={20}
visibleToasts={4}
className={fontClass}
toastOptions={{
unstyled: true,
style: {
fontFamily: isEn
? "var(--font-inter), system-ui, sans-serif"
: "var(--font-vazirmatn), system-ui, sans-serif",
},
classNames: {
toast: toastBase,
title: titleClass,
description: descriptionClass,
content: "flex flex-1 flex-col gap-0.5 min-w-0",
closeButton: cn(
"absolute end-2.5 top-2.5 flex h-7 w-7 items-center justify-center rounded-lg",
"border-0 bg-muted/50 text-muted-foreground opacity-80",
"transition hover:bg-muted hover:opacity-100",
fontClass
),
actionButton: cn(
"rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground",
fontClass
),
cancelButton: cn(
"rounded-lg border border-border/80 bg-background px-3 py-1.5 text-xs font-medium",
fontClass
),
success: "border-s-[3px] border-s-[#0F6E56]",
error: "border-s-[3px] border-s-[#A32D2D]",
warning: "border-s-[3px] border-s-[#BA7517]",
info: "border-s-[3px] border-s-[#0C447C]",
loading: "border-s-[3px] border-s-primary/40",
},
}}
icons={{
success: iconWrap(
"bg-[#E1F5EE]",
<CheckCircle2 className="h-4 w-4 text-[#0F6E56]" strokeWidth={2.25} />
),
error: iconWrap(
"bg-red-50",
<AlertCircle className="h-4 w-4 text-[#A32D2D]" strokeWidth={2.25} />
),
warning: iconWrap(
"bg-amber-50",
<TriangleAlert className="h-4 w-4 text-[#BA7517]" strokeWidth={2.25} />
),
info: iconWrap(
"bg-[#0C447C]/10",
<Info className="h-4 w-4 text-[#0C447C]" strokeWidth={2.25} />
),
loading: iconWrap(
"bg-primary/10",
<Loader2 className="h-4 w-4 animate-spin text-primary" strokeWidth={2.25} />
),
}}
/>
);
}
@@ -0,0 +1,11 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}