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,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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user