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