feat(dashboard): Next.js 16 merchant panel with offline POS and PWA
Complete merchant dashboard upgrade:
Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors
Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect
PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user