feat(dashboard): Next.js 16 merchant panel with offline POS and PWA

Complete merchant dashboard upgrade:

Next.js 16 compatibility:
- Fix params/searchParams typed as Promise<{}> throughout App Router
- Replace middleware.ts with proxy.ts (Next.js 16 convention)
- Remove unused @ts-expect-error directives caught by stricter TS
- Cast dynamic next-intl t() keys to fix TranslateArgs type errors

Offline POS:
- IndexedDB queue (meezi_pos_offline) for orders created while offline
- Zustand sync store tracking queueCount, isSyncing, isOnline
- useOfflineSync hook: auto-syncs on reconnect/visibility-change
- SyncStatusIndicator chip in topbar (amber=offline, blue=syncing)
- submitOrderToApi falls back to local order on network failure
- Local orders skip payment flow; sync on reconnect

PWA (installable):
- @ducanh2912/next-pwa with Workbox runtime caching rules
- Web App Manifest (manifest.ts) — RTL/Farsi, theme #0F6E56
- PWA icons: 192px, 512px, maskable 512px
- next.config.ts replaces next.config.mjs

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,173 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CafeTheme } from "@/lib/cafe-theme";
import { normalizeMenuTexture, resolveThemeColors } from "@/lib/cafe-theme";
import { qrMenuTextureShellProps } from "@/lib/qr-menu-texture";
const PREVIEW_SAMPLES = [
{ name: "اسپرسو", price: "۸۵٬۰۰۰ ت" },
{ name: "کاپوچینو", price: "۱۲۰٬۰۰۰ ت" },
] as const;
type GuestMenuTemplatePreviewProps = {
theme: CafeTheme;
cafeName?: string;
};
export function GuestMenuTemplatePreview({
theme,
cafeName = "کافه نمونه",
}: GuestMenuTemplatePreviewProps) {
const t = useTranslations("settings.appearance");
const colors = resolveThemeColors(theme);
const style = theme.menuStyle;
const textureShell = qrMenuTextureShellProps(
normalizeMenuTexture(theme.menuTexture),
colors.background
);
return (
<div className="flex flex-col items-center gap-3">
<p className="text-xs text-muted-foreground">{t("guestMenuPreviewHint")}</p>
<div
className="relative w-[220px] overflow-hidden rounded-[1.75rem] border-[6px] border-neutral-800 bg-neutral-900 shadow-xl"
dir="rtl"
>
<div
className="h-[380px] overflow-hidden"
data-qr-texture={textureShell["data-qr-texture"]}
style={textureShell.style}
>
<div
className="border-b px-3 py-3 text-center"
style={{ backgroundColor: colors.surface }}
>
<div
className="mx-auto mb-1 size-8 rounded-full"
style={{ backgroundColor: colors.primary }}
/>
<p className="text-[11px] font-bold" style={{ color: colors.text }}>
{cafeName}
</p>
<p className="text-[9px]" style={{ color: colors.textMuted }}>
{t(`menuStyles.${style}`)} · {t(`menuTextures.${normalizeMenuTexture(theme.menuTexture)}`)}
</p>
</div>
{style === "classic" ? (
<div className="flex h-[calc(100%-4.5rem)]">
<div
className="flex w-12 flex-col gap-1 border-e py-2"
style={{ backgroundColor: colors.surface }}
>
{["☕", "🍰", "🥤"].map((icon, i) => (
<div
key={icon}
className={cn(
"mx-auto flex size-8 items-center justify-center rounded-md text-xs",
i === 0 ? "text-white" : ""
)}
style={i === 0 ? { backgroundColor: colors.primary } : undefined}
>
{icon}
</div>
))}
</div>
<PreviewItems colors={colors} layout="list" />
</div>
) : style === "grid" ? (
<div className="grid grid-cols-2 gap-1.5 p-2">
{PREVIEW_SAMPLES.map((item) => (
<PreviewCard key={item.name} item={item} colors={colors} />
))}
</div>
) : style === "magazine" ? (
<div className="space-y-2 p-2">
{PREVIEW_SAMPLES.map((item) => (
<div
key={item.name}
className="overflow-hidden rounded-lg border shadow-sm"
style={{
borderColor: `${colors.textMuted}33`,
backgroundColor: colors.surface,
}}
>
<div
className="h-14"
style={{ backgroundColor: colors.secondary }}
/>
<div className="p-1.5">
<p className="text-[10px] font-semibold">{item.name}</p>
<p className="text-[9px]" style={{ color: colors.primary }}>
{item.price}
</p>
</div>
</div>
))}
</div>
) : (
<PreviewItems
colors={colors}
layout={style === "compact" ? "compact" : "list"}
/>
)}
</div>
</div>
</div>
);
}
function PreviewItems({
colors,
layout,
}: {
colors: ReturnType<typeof resolveThemeColors>;
layout: "list" | "compact";
}) {
return (
<div className="flex-1 space-y-1.5 p-2">
{PREVIEW_SAMPLES.map((item) => (
<div
key={item.name}
className={cn(
"flex items-center justify-between rounded border px-2 shadow-sm",
layout === "compact" ? "py-1" : "py-1.5"
)}
style={{
borderColor: `${colors.textMuted}33`,
backgroundColor: colors.surface,
}}
>
<span className="text-[10px] font-medium">{item.name}</span>
<span className="text-[9px] font-semibold" style={{ color: colors.primary }}>
{item.price}
</span>
</div>
))}
</div>
);
}
function PreviewCard({
item,
colors,
}: {
item: (typeof PREVIEW_SAMPLES)[number];
colors: ReturnType<typeof resolveThemeColors>;
}) {
return (
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: `${colors.textMuted}33`, backgroundColor: colors.surface }}
>
<div className="aspect-square" style={{ backgroundColor: colors.secondary }} />
<div className="p-1">
<p className="text-[9px] font-semibold">{item.name}</p>
<p className="text-[8px]" style={{ color: colors.primary }}>
{item.price}
</p>
</div>
</div>
);
}
@@ -0,0 +1,474 @@
"use client";
import { useTranslations } from "next-intl";
import { Check, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatCurrency, formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
const PRICES: Record<PlanId, number | null> = {
Free: 0,
Pro: 1_490_000,
Business: 3_490_000,
Enterprise: null,
};
type CellValue =
| { kind: "bool"; value: boolean }
| { kind: "limit"; value: number | null }
| { kind: "text"; value: string };
type FeatureRow = {
key: string;
cells: Record<PlanId, CellValue>;
};
const FEATURE_MATRIX: FeatureRow[] = [
{
key: "ordersPerDay",
cells: {
Free: { kind: "limit", value: 50 },
Pro: { kind: "limit", value: null },
Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "terminals",
cells: {
Free: { kind: "limit", value: 1 },
Pro: { kind: "limit", value: 3 },
Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "crmCustomers",
cells: {
Free: { kind: "limit", value: 50 },
Pro: { kind: "limit", value: null },
Business: { kind: "limit", value: null },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "smsPerMonth",
cells: {
Free: { kind: "limit", value: 0 },
Pro: { kind: "limit", value: 50 },
Business: { kind: "limit", value: 200 },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "branches",
cells: {
Free: { kind: "limit", value: 1 },
Pro: { kind: "limit", value: 1 },
Business: { kind: "limit", value: 5 },
Enterprise: { kind: "limit", value: null },
},
},
{
key: "posKds",
cells: {
Free: { kind: "bool", value: true },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "tablesQr",
cells: {
Free: { kind: "bool", value: true },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "menuReservations",
cells: {
Free: { kind: "bool", value: true },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "reports",
cells: {
Free: { kind: "text", value: "basic" },
Pro: { kind: "text", value: "full" },
Business: { kind: "text", value: "full" },
Enterprise: { kind: "text", value: "full" },
},
},
{
key: "hrModule",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "snappfoodDelivery",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "tarazTax",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: true },
Business: { kind: "bool", value: true },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "badges",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: false },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "whiteLabel",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: false },
Enterprise: { kind: "bool", value: true },
},
},
{
key: "apiAccess",
cells: {
Free: { kind: "bool", value: false },
Pro: { kind: "bool", value: false },
Business: { kind: "bool", value: false },
Enterprise: { kind: "bool", value: true },
},
},
];
function CellDisplay({
cell,
t,
numberLocale,
}: {
cell: CellValue;
t: ReturnType<typeof useTranslations<"settings.plans">>;
numberLocale: string;
}) {
if (cell.kind === "bool") {
return cell.value ? (
<Check className="mx-auto h-5 w-5 text-[#0F6E56]" aria-hidden />
) : (
<X className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />
);
}
if (cell.kind === "limit") {
if (cell.value === null) {
return (
<span className="text-sm font-medium text-[#0F6E56]">{t("unlimited")}</span>
);
}
if (cell.value === 0) {
return <Minus className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />;
}
return (
<span className="text-sm font-medium">{formatNumber(cell.value, numberLocale)}</span>
);
}
return (
<span className="text-xs font-medium text-muted-foreground">
{t(`levels.${cell.value}`)}
</span>
);
}
type PlanComparisonProps = {
currentPlan?: string;
onSubscribe: (planTier: "Pro" | "Business") => void;
isSubscribing?: boolean;
};
export function PlanComparison({
currentPlan = "Free",
onSubscribe,
isSubscribing = false,
}: PlanComparisonProps) {
const t = useTranslations("settings.plans");
const tSettings = useTranslations("settings");
const numberLocale =
typeof document !== "undefined" && document.documentElement.lang === "en"
? "en-US"
: "fa-IR";
const normalizedCurrent = currentPlan as PlanId;
return (
<section className="relative z-0 mb-8 space-y-4 scroll-mt-6">
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("compareLabel")}
</p>
<h3 className="text-lg font-medium text-foreground">{tSettings("upgrade")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{t("compareHint")}</p>
</div>
{/* Desktop comparison table — badges in-flow; CTAs outside scroll clip */}
<div className="relative z-0 mb-2 hidden overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm lg:block">
<div className="overflow-x-auto overscroll-x-contain">
<div className="min-w-[720px] px-2 pb-2 pt-4">
<table className="w-full border-collapse text-center text-sm">
<thead>
<tr className="border-b border-border/80">
<th className="w-[28%] bg-muted/30 px-4 pb-4 pt-2 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("featureColumn")}
</th>
{PLAN_ORDER.map((plan) => {
const isCurrent = plan === normalizedCurrent;
const isPopular = plan === "Pro";
return (
<th
key={plan}
className={cn(
"px-3 pb-4 pt-2 align-top",
isPopular && "bg-[#E1F5EE]/60",
isCurrent && "ring-2 ring-inset ring-[#0F6E56]/40"
)}
>
<div className="flex flex-col items-center gap-2">
<div className="flex min-h-[1.375rem] flex-wrap items-center justify-center gap-1.5">
{isPopular ? (
<Badge className="whitespace-nowrap border-[#0F6E56]/30 bg-[#0F6E56] px-2.5 py-0.5 text-[10px] text-white hover:bg-[#0F6E56]">
{t("popular")}
</Badge>
) : null}
{isCurrent ? (
<Badge
variant="outline"
className="whitespace-nowrap border-[#0F6E56]/30 bg-white px-2.5 py-0.5 text-[10px] text-[#0F6E56]"
>
{t("current")}
</Badge>
) : null}
</div>
<div className="text-base font-semibold text-foreground">
{t(`names.${plan}`)}
</div>
<p className="font-medium text-[#0F6E56]">
{PRICES[plan] === null
? t("customPrice")
: PRICES[plan] === 0
? t("freePrice")
: formatCurrency(PRICES[plan]!, numberLocale)}
</p>
<p className="text-[11px] text-muted-foreground">{t("perMonth")}</p>
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{FEATURE_MATRIX.map((row, idx) => (
<tr
key={row.key}
className={cn(
"border-b border-border/60",
idx % 2 === 0 ? "bg-background" : "bg-muted/20"
)}
>
<td className="px-4 py-3 text-start text-sm text-foreground">
{t(`features.${row.key}`)}
</td>
{PLAN_ORDER.map((plan) => (
<td
key={plan}
className={cn(
"px-3 py-3",
plan === "Pro" && "bg-[#E1F5EE]/30",
plan === normalizedCurrent && "bg-[#E1F5EE]/50"
)}
>
<CellDisplay
cell={row.cells[plan]}
t={t}
numberLocale={numberLocale}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="overflow-x-auto overscroll-x-contain border-t border-border/80 bg-muted/10">
<div className="grid min-w-[720px] grid-cols-[28%_repeat(4,minmax(0,1fr))] items-center gap-0 px-2 py-5">
<div className="px-4" aria-hidden />
{PLAN_ORDER.map((plan) => (
<div
key={plan}
className={cn(
"px-3",
plan === "Pro" && "bg-[#E1F5EE]/30",
plan === normalizedCurrent && "bg-[#E1F5EE]/50"
)}
>
<PlanCta
plan={plan}
currentPlan={normalizedCurrent}
onSubscribe={onSubscribe}
isSubscribing={isSubscribing}
t={t}
fullWidth
/>
</div>
))}
</div>
</div>
</div>
{/* Mobile plan cards */}
<div className="grid gap-4 lg:hidden">
{PLAN_ORDER.map((plan) => {
const isCurrent = plan === normalizedCurrent;
const isPopular = plan === "Pro";
return (
<article
key={plan}
className={cn(
"relative rounded-xl border bg-card p-4 shadow-sm",
isPopular ? "border-[#0F6E56] ring-1 ring-[#0F6E56]/30" : "border-border/80",
isCurrent && "ring-2 ring-[#0F6E56]/50"
)}
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<h4 className="text-base font-semibold">{t(`names.${plan}`)}</h4>
{isPopular && (
<Badge className="bg-[#0F6E56] text-white hover:bg-[#0F6E56]">
{t("popular")}
</Badge>
)}
{isCurrent && (
<Badge variant="outline" className="border-[#0F6E56]/30 text-[#0F6E56]">
{t("current")}
</Badge>
)}
</div>
<p className="mb-4 text-lg font-medium text-[#0F6E56]">
{PRICES[plan] === null
? t("customPrice")
: PRICES[plan] === 0
? t("freePrice")
: formatCurrency(PRICES[plan]!, numberLocale)}
{PRICES[plan] !== null && PRICES[plan]! > 0 && (
<span className="ms-1 text-xs font-normal text-muted-foreground">
{t("perMonth")}
</span>
)}
</p>
<ul className="mb-4 space-y-2 border-t border-border/60 pt-3">
{FEATURE_MATRIX.map((row) => (
<li
key={row.key}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="text-muted-foreground">{t(`features.${row.key}`)}</span>
<CellDisplay
cell={row.cells[plan]}
t={t}
numberLocale={numberLocale}
/>
</li>
))}
</ul>
<PlanCta
plan={plan}
currentPlan={normalizedCurrent}
onSubscribe={onSubscribe}
isSubscribing={isSubscribing}
t={t}
fullWidth
/>
</article>
);
})}
</div>
</section>
);
}
function PlanCta({
plan,
currentPlan,
onSubscribe,
isSubscribing,
t,
fullWidth,
}: {
plan: PlanId;
currentPlan: PlanId;
onSubscribe: (planTier: "Pro" | "Business") => void;
isSubscribing: boolean;
t: ReturnType<typeof useTranslations<"settings.plans">>;
fullWidth?: boolean;
}) {
const isCurrent = plan === currentPlan;
if (plan === "Free") {
return (
<Button variant="outline" disabled className={fullWidth ? "w-full" : ""} size="sm">
{isCurrent ? t("currentPlanBtn") : t("included")}
</Button>
);
}
if (plan === "Enterprise") {
return (
<Button variant="outline" className={fullWidth ? "w-full" : ""} size="sm" asChild>
<a href="mailto:sales@meezi.ir">{t("contactSales")}</a>
</Button>
);
}
if (isCurrent) {
return (
<Button variant="secondary" disabled className={fullWidth ? "w-full" : ""} size="sm">
{t("currentPlanBtn")}
</Button>
);
}
return (
<Button
className={cn(
"bg-[#0F6E56] hover:bg-[#0c5a46]",
fullWidth ? "w-full" : ""
)}
size="sm"
disabled={isSubscribing}
onClick={() => onSubscribe(plan)}
>
{t("subscribe", { plan: t(`names.${plan}`) })}
</Button>
);
}
@@ -0,0 +1,439 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import {
applyCafeTheme,
CAFE_MENU_STYLES,
CAFE_MENU_TEXTURES,
CAFE_PANEL_STYLES,
CAFE_THEME_DENSITIES,
CAFE_THEME_PALETTES,
CAFE_THEME_RADIUS,
DEFAULT_CAFE_THEME,
normalizeCafeTheme,
resolveThemeColors,
COLOR_OPACITY_KEYS,
type CafeTheme,
type CafeThemeColorKey,
type CafeThemeCustomColors,
} from "@/lib/cafe-theme";
import { apiPatch } from "@/lib/api/client";
import { cafeSettingsQueryKey, useCafeSettings, type CafeSettings } from "@/lib/hooks/use-cafe-settings";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
import { GuestMenuTemplatePreview } from "@/components/settings/guest-menu-template-preview";
type SettingsAppearancePanelProps = {
cafeId: string;
};
const CUSTOM_COLOR_KEYS: CafeThemeColorKey[] = [
"primary",
"secondary",
"accent",
"background",
"surface",
"text",
"textMuted",
"destructive",
"success",
];
export function SettingsAppearancePanel({ cafeId }: SettingsAppearancePanelProps) {
const t = useTranslations("settings.appearance");
const tCommon = useTranslations("common");
const queryClient = useQueryClient();
const { data: cafeSettings } = useCafeSettings(cafeId);
const [theme, setTheme] = useState<CafeTheme>(DEFAULT_CAFE_THEME);
useEffect(() => {
if (!cafeSettings?.theme) return;
setTheme(normalizeCafeTheme(cafeSettings.theme));
}, [cafeSettings?.theme]);
const previewColors = useMemo(() => resolveThemeColors(theme), [theme]);
useEffect(() => {
applyCafeTheme(theme);
}, [theme]);
const setCustom = (key: CafeThemeColorKey, value: string) => {
setTheme((prev) => ({
...prev,
custom: { ...(prev.custom ?? {}), [key]: value || null },
}));
};
const setCustomOpacity = (key: CafeThemeColorKey, value: number) => {
const opacityKey = COLOR_OPACITY_KEYS[key];
setTheme((prev) => ({
...prev,
custom: { ...(prev.custom ?? {}), [opacityKey]: value },
}));
};
const clearCustom = () => {
setTheme((prev) => ({ ...prev, custom: null }));
};
const save = useMutation({
mutationFn: () =>
apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, { theme }),
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success(t("saved"));
},
});
return (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("paletteSection")}
</p>
<CardTitle className="text-base">{t("paletteTitle")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("paletteHint")}</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{CAFE_THEME_PALETTES.map((palette) => {
const selected = theme.paletteId === palette.id;
return (
<button
key={palette.id}
type="button"
onClick={() => setTheme((p) => ({ ...p, paletteId: palette.id }))}
className={cn(
"flex items-center gap-2 rounded-lg border p-2 text-start transition-all active:scale-[0.98]",
selected
? "border-primary bg-accent ring-1 ring-primary/30"
: "border-border/80 hover:border-primary/40"
)}
>
<span
className="h-8 w-8 shrink-0 rounded-md border border-black/10 shadow-inner"
style={{
background: `linear-gradient(135deg, ${palette.primary} 50%, ${palette.secondary} 50%)`,
}}
/>
<span className="min-w-0 truncate text-xs font-medium">
{t(`palettes.${palette.id}`)}
</span>
</button>
);
})}
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("dashboardSection")}
</p>
<CardTitle className="text-base">{t("dashboardTitle")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("dashboardDesc")}</p>
</CardHeader>
<CardContent className="space-y-4">
<StyleChipRow
label={t("panelStyle")}
options={CAFE_PANEL_STYLES}
value={theme.panelStyle}
labelPrefix="panelStyles"
onChange={(panelStyle) => setTheme((p) => ({ ...p, panelStyle }))}
/>
<StyleChipRow
label={t("density")}
options={CAFE_THEME_DENSITIES}
value={theme.density}
labelPrefix="densities"
onChange={(density) => setTheme((p) => ({ ...p, density }))}
/>
<StyleChipRow
label={t("radius")}
options={CAFE_THEME_RADIUS}
value={theme.radius}
labelPrefix="radiusOptions"
onChange={(radius) => setTheme((p) => ({ ...p, radius }))}
/>
<div className="border-t border-border/60 pt-4">
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("dashboardPreviewSection")}
</p>
<p className="mb-3 text-sm font-medium text-foreground">{t("dashboardPreviewTitle")}</p>
<div
className="rounded-xl border p-4 transition-colors"
style={{ background: previewColors.background }}
data-panel-style={theme.panelStyle}
>
<div className="flex gap-3">
<div
className="theme-preview-sidebar w-24 shrink-0 rounded-lg p-2 shadow-sm"
style={{ background: previewColors.surface }}
>
<div
className="mb-2 h-6 rounded-md px-2 text-[10px] font-medium leading-6 text-white"
style={{ background: previewColors.primary }}
>
{t("previewNav")}
</div>
<div
className="h-5 rounded-md opacity-80"
style={{ background: previewColors.secondary }}
/>
<div
className="mt-1.5 h-5 rounded-md opacity-60"
style={{ background: previewColors.secondary }}
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div
className="theme-preview-menu-card rounded-lg border p-3 shadow-sm"
style={{
background: previewColors.surface,
borderColor: `${previewColors.primary}33`,
}}
>
<p className="text-sm font-medium" style={{ color: previewColors.text }}>
{t("previewItem")}
</p>
<p className="text-xs" style={{ color: previewColors.textMuted }}>
۱۲۰٬۰۰۰ ت
</p>
<span
className="mt-2 inline-block rounded-md px-2 py-0.5 text-[10px] text-white"
style={{ background: previewColors.primary }}
>
{t("previewCta")}
</span>
</div>
</div>
</div>
</div>
<p className="mt-2 text-[11px] text-muted-foreground">{t("dashboardPreviewHint")}</p>
</div>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("guestMenuSection")}
</p>
<CardTitle className="text-base">{t("guestMenuTitle")}</CardTitle>
<p className="text-xs text-muted-foreground">{t("guestMenuDesc")}</p>
</CardHeader>
<CardContent className="space-y-4">
<StyleChipRow
label={t("guestMenuStyle")}
options={CAFE_MENU_STYLES}
value={theme.menuStyle}
labelPrefix="menuStyles"
onChange={(menuStyle) => setTheme((p) => ({ ...p, menuStyle }))}
/>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{t("menuTexture")}</p>
<TextureSwatchGrid
textures={CAFE_MENU_TEXTURES}
value={theme.menuTexture}
backgroundColor={previewColors.background}
onChange={(menuTexture) => setTheme((p) => ({ ...p, menuTexture }))}
labelPrefix="menuTextures"
/>
</div>
<div className="border-t border-border/60 pt-4">
<p className="mb-3 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("guestMenuPreviewSection")}
</p>
<GuestMenuTemplatePreview
theme={theme}
cafeName={cafeSettings?.name ?? undefined}
/>
</div>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="pb-2">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("customSection")}
</p>
<CardTitle className="text-base">{t("customTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">{t("customHint")}</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{CUSTOM_COLOR_KEYS.map((key) => {
const base = resolveThemeColors({ ...theme, custom: null });
const value = theme.custom?.[key] ?? paletteColorForKey(base, key);
const opacityKey = COLOR_OPACITY_KEYS[key];
const opacity =
(theme.custom?.[opacityKey] as number | null | undefined) ?? 100;
return (
<LabeledField key={key} label={t(`colors.${key}`)}>
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={value.startsWith("#") ? value : "#0F6E56"}
onChange={(e) => setCustom(key, e.target.value)}
className="h-9 w-12 cursor-pointer rounded border border-border/80 bg-card"
/>
<input
type="text"
value={theme.custom?.[key] ?? ""}
placeholder={value}
onChange={(e) => setCustom(key, e.target.value)}
className="flex-1 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
dir="ltr"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{t("colorOpacity")}</span>
<input
type="range"
min={0}
max={100}
value={opacity}
onChange={(e) => setCustomOpacity(key, Number(e.target.value))}
className="flex-1"
/>
<span className="w-8 text-end font-mono text-[11px] text-muted-foreground" dir="ltr">
{opacity}%
</span>
</div>
</div>
</LabeledField>
);
})}
</div>
<Button type="button" size="sm" variant="ghost" onClick={clearCustom}>
{t("resetCustom")}
</Button>
</CardContent>
</Card>
<Button
className="bg-primary text-primary-foreground hover:opacity-90"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{tCommon("save")}
</Button>
</div>
);
}
function paletteColorForKey(
palette: ReturnType<typeof resolveThemeColors>,
key: CafeThemeColorKey
): string {
switch (key) {
case "primary":
return palette.primary;
case "secondary":
return palette.secondary;
case "accent":
return palette.accent;
case "background":
return palette.background;
case "surface":
return palette.surface;
case "text":
return palette.text;
case "textMuted":
return palette.textMuted;
case "destructive":
return palette.destructive;
case "success":
return palette.success;
default:
return palette.primary;
}
}
function TextureSwatchGrid<T extends string>({
textures,
value,
backgroundColor,
onChange,
labelPrefix,
}: {
textures: readonly T[];
value: T;
backgroundColor: string;
onChange: (v: T) => void;
labelPrefix: string;
}) {
const t = useTranslations("settings.appearance");
return (
<div className="grid grid-cols-4 gap-2 sm:grid-cols-8">
{textures.map((tex) => {
const selected = value === tex;
return (
<button
key={tex}
type="button"
title={t(`${labelPrefix}.${tex}` as any)}
onClick={() => onChange(tex)}
className={cn(
"qr-texture-swatch transition-all active:scale-[0.98]",
selected ? "ring-2 ring-primary ring-offset-1" : "opacity-90 hover:opacity-100"
)}
data-qr-texture={tex}
style={{ ["--qr-bg" as string]: backgroundColor }}
>
<span className="sr-only">{t(`${labelPrefix}.${tex}` as any)}</span>
</button>
);
})}
</div>
);
}
function StyleChipRow<T extends string>({
label,
options,
value,
labelPrefix,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
labelPrefix: string;
onChange: (v: T) => void;
}) {
const t = useTranslations("settings.appearance");
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => (
<button
key={opt}
type="button"
onClick={() => onChange(opt)}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
value === opt
? "border-primary bg-accent text-primary"
: "border-border/80 text-muted-foreground hover:border-primary/40"
)}
>
{t(`${labelPrefix}.${opt}` as any)}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import {
SETTINGS_NAV,
type SettingsGroupId,
type SettingsLeafId,
groupForLeaf,
} from "@/components/settings/settings-types";
type SettingsNavProps = {
activeLeaf: SettingsLeafId;
expandedGroup: SettingsGroupId;
onSelectLeaf: (leaf: SettingsLeafId) => void;
onToggleGroup: (group: SettingsGroupId) => void;
};
export function SettingsNav({
activeLeaf,
expandedGroup,
onSelectLeaf,
onToggleGroup,
}: SettingsNavProps) {
const t = useTranslations("settings");
return (
<nav
className="shrink-0 rounded-xl border border-border/80 bg-card p-3 shadow-sm md:w-52 lg:w-56"
aria-label={t("nav.aria")}
>
<ul className="space-y-1.5">
{SETTINGS_NAV.map((group) => {
const isExpanded = expandedGroup === group.id;
const groupActive = groupForLeaf(activeLeaf) === group.id;
return (
<li key={group.id}>
<button
type="button"
onClick={() => onToggleGroup(group.id)}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-start text-sm font-medium transition",
groupActive
? "bg-[#E1F5EE] text-[#0F6E56]"
: "text-foreground hover:bg-muted/60"
)}
>
<span className="min-w-0 flex-1 leading-snug">{t(group.labelKey)}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-180",
groupActive && "text-[#0F6E56]"
)}
aria-hidden
/>
</button>
{isExpanded ? (
<ul className="me-1 ms-3 mt-1.5 space-y-1 border-s-2 border-[#0F6E56]/25 ps-4">
{group.children.map((child) => {
const isActive = activeLeaf === child.id;
return (
<li key={child.id}>
<button
type="button"
onClick={() => onSelectLeaf(child.id)}
className={cn(
"w-full rounded-lg px-3 py-2 text-start text-[13px] leading-snug transition",
isActive
? "bg-[#0F6E56] text-white shadow-sm"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t(child.labelKey)}
</button>
</li>
);
})}
</ul>
) : null}
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Printer } from "lucide-react";
import { apiGet } from "@/lib/api/client";
import { printErrorMessage, testPrinter } from "@/lib/api/print";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type BranchPrintSettings = {
receiptPrinterIp?: string | null;
receiptPrinterPort?: number | null;
kitchenPrinterIp?: string | null;
kitchenPrinterPort?: number | null;
};
type SettingsPrintTestPanelProps = {
cafeId: string;
onOpenPrinterSettings?: () => void;
};
function printerEndpointLabel(
ip: string | null | undefined,
port: number | null | undefined
): string {
if (!ip?.trim()) return "—";
return `${ip.trim()}:${port ?? 9100}`;
}
export function SettingsPrintTestPanel({
cafeId,
onOpenPrinterSettings,
}: SettingsPrintTestPanelProps) {
const t = useTranslations("print");
const tSettings = useTranslations("settings");
const tCommon = useTranslations("common");
const [message, setMessage] = useState<string | null>(null);
const [lastTarget, setLastTarget] = useState<"receipt" | "kitchen" | null>(null);
const { data: branches = [], isLoading: branchesLoading } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
const branchId = branches[0]?.id;
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["branch-print-settings", cafeId, branchId],
queryFn: () =>
apiGet<BranchPrintSettings>(
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`
),
enabled: !!cafeId && !!branchId,
});
const runTest = useMutation({
mutationFn: (target: "receipt" | "kitchen") => {
const ip =
target === "receipt"
? settings?.receiptPrinterIp?.trim()
: settings?.kitchenPrinterIp?.trim();
const port =
target === "receipt"
? settings?.receiptPrinterPort ?? 9100
: settings?.kitchenPrinterPort ?? 9100;
if (!ip) throw new Error("PRINTER_NOT_CONFIGURED");
return testPrinter(cafeId, ip, port);
},
onMutate: (target) => setLastTarget(target),
onSuccess: () => setMessage(t("success")),
onError: (err) => setMessage(printErrorMessage(err, t)),
});
const isLoading = branchesLoading || settingsLoading;
const receiptReady = !!settings?.receiptPrinterIp?.trim();
const kitchenReady = !!settings?.kitchenPrinterIp?.trim();
if (isLoading) {
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
}
if (!branchId) {
return (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{t("noBranchForPrinter")}</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="space-y-2 px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{tSettings("nav.printTest")}</CardTitle>
<p className="text-sm leading-relaxed text-muted-foreground">{t("testPageHint")}</p>
</CardHeader>
<CardContent className="space-y-5 px-6 pb-6 pt-0">
{message ? (
<p
className={cn(
"rounded-md border px-3 py-2 text-sm",
lastTarget && runTest.isSuccess
? "border-[#0F6E56]/30 bg-[#E1F5EE] text-[#0F6E56]"
: "border-border/80 bg-muted/40"
)}
>
{message}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-border/80 bg-card p-5 space-y-4">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#E1F5EE] text-[#0F6E56]">
<Printer className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-medium">{t("receiptPrinter")}</p>
<p className="text-[11px] text-muted-foreground" dir="ltr">
{printerEndpointLabel(
settings?.receiptPrinterIp,
settings?.receiptPrinterPort
)}
</p>
</div>
</div>
<Button
className="w-full bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={!receiptReady || runTest.isPending}
onClick={() => runTest.mutate("receipt")}
>
{t("testPrintReceipt")}
</Button>
{!receiptReady ? (
<p className="text-[11px] text-[#BA7517]">{t("notConfigured")}</p>
) : null}
</div>
<div className="rounded-xl border border-border/80 bg-card p-5 space-y-4">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-[#0C447C]">
<Printer className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-medium">{t("kitchenPrinter")}</p>
<p className="text-[11px] text-muted-foreground" dir="ltr">
{printerEndpointLabel(
settings?.kitchenPrinterIp,
settings?.kitchenPrinterPort
)}
</p>
</div>
</div>
<Button
className="w-full"
variant="outline"
disabled={!kitchenReady || runTest.isPending}
onClick={() => runTest.mutate("kitchen")}
>
{t("testPrintKitchen")}
</Button>
{!kitchenReady ? (
<p className="text-[11px] text-[#BA7517]">{t("notConfigured")}</p>
) : null}
</div>
</div>
{onOpenPrinterSettings ? (
<Button variant="ghost" size="sm" onClick={onOpenPrinterSettings}>
{t("configurePrinters")}
</Button>
) : null}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,270 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPatch } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
type BranchPrintSettings = {
branchId: string;
receiptPrinterIp?: string | null;
receiptPrinterPort?: number | null;
kitchenPrinterIp?: string | null;
kitchenPrinterPort?: number | null;
paperWidthMm: number;
autoCutEnabled: boolean;
receiptHeader?: string | null;
receiptFooter?: string | null;
wifiPassword?: string | null;
posDeviceIp?: string | null;
posDevicePort?: number | null;
};
type SettingsPrinterPanelProps = {
cafeId: string;
onOpenPrintTest?: () => void;
};
export function SettingsPrinterPanel({ cafeId, onOpenPrintTest }: SettingsPrinterPanelProps) {
const t = useTranslations("print");
const tSettings = useTranslations("settings");
const tCommon = useTranslations("common");
const [message, setMessage] = useState<string | null>(null);
const [receiptIp, setReceiptIp] = useState("");
const [receiptPort, setReceiptPort] = useState("9100");
const [kitchenIp, setKitchenIp] = useState("");
const [kitchenPort, setKitchenPort] = useState("9100");
const [paperWidth, setPaperWidth] = useState("80");
const [autoCut, setAutoCut] = useState(true);
const [receiptHeader, setReceiptHeader] = useState("");
const [receiptFooter, setReceiptFooter] = useState("");
const [wifiPassword, setWifiPassword] = useState("");
const [posDeviceIp, setPosDeviceIp] = useState("");
const [posDevicePort, setPosDevicePort] = useState("8088");
const { data: branches = [], isLoading: branchesLoading } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<{ id: string }[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
const branchId = branches[0]?.id;
const { data: settings, refetch } = useQuery({
queryKey: ["branch-print-settings", cafeId, branchId],
queryFn: () =>
apiGet<BranchPrintSettings>(
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`
),
enabled: !!cafeId && !!branchId,
});
useEffect(() => {
if (!settings) return;
setReceiptIp(settings.receiptPrinterIp ?? "");
setReceiptPort(String(settings.receiptPrinterPort ?? 9100));
setKitchenIp(settings.kitchenPrinterIp ?? "");
setKitchenPort(String(settings.kitchenPrinterPort ?? 9100));
setPaperWidth(String(settings.paperWidthMm === 58 ? 58 : 80));
setAutoCut(settings.autoCutEnabled);
setReceiptHeader(settings.receiptHeader ?? "");
setReceiptFooter(settings.receiptFooter ?? "");
setWifiPassword(settings.wifiPassword ?? "");
setPosDeviceIp(settings.posDeviceIp ?? "");
setPosDevicePort(String(settings.posDevicePort ?? 8088));
}, [settings]);
const save = useMutation({
mutationFn: () =>
apiPatch<BranchPrintSettings>(
`/api/cafes/${cafeId}/branches/${branchId}/print-settings`,
{
receiptPrinterIp: receiptIp.trim() || null,
receiptPrinterPort: parseInt(receiptPort, 10) || 9100,
kitchenPrinterIp: kitchenIp.trim() || null,
kitchenPrinterPort: parseInt(kitchenPort, 10) || 9100,
paperWidthMm: paperWidth === "58" ? 58 : 80,
autoCutEnabled: autoCut,
receiptHeader: receiptHeader.trim() || null,
receiptFooter: receiptFooter.trim() || null,
wifiPassword: wifiPassword.trim() || null,
posDeviceIp: posDeviceIp.trim() || null,
posDevicePort: parseInt(posDevicePort, 10) || 8088,
}
),
onSuccess: () => {
setMessage(t("settingsSaved"));
void refetch();
},
});
if (branchesLoading) {
return <p className="text-sm text-muted-foreground">{tCommon("loading")}</p>;
}
if (!branchId) {
return (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">{t("noBranchForPrinter")}</p>
</CardContent>
</Card>
);
}
return (
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0 px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("printerSettings")}</CardTitle>
{onOpenPrintTest ? (
<Button variant="outline" size="sm" onClick={onOpenPrintTest}>
{tSettings("nav.printTest")}
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-6 px-6 pb-6 pt-0">
{message ? (
<p className="rounded-lg border border-border/80 bg-muted/40 px-4 py-2.5 text-xs">
{message}
</p>
) : null}
<section className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("receiptPrinter")} htmlFor="receipt-ip">
<Input
id="receipt-ip"
value={receiptIp}
onChange={(e) => setReceiptIp(e.target.value)}
placeholder="192.168.1.100"
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("port")} htmlFor="receipt-port">
<Input
id="receipt-port"
value={receiptPort}
onChange={(e) => setReceiptPort(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("kitchenPrinter")} htmlFor="kitchen-ip">
<Input
id="kitchen-ip"
value={kitchenIp}
onChange={(e) => setKitchenIp(e.target.value)}
placeholder="192.168.1.101"
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("port")} htmlFor="kitchen-port">
<Input
id="kitchen-port"
value={kitchenPort}
onChange={(e) => setKitchenPort(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("paperWidth")} htmlFor="paper-width">
<select
id="paper-width"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
value={paperWidth}
onChange={(e) => setPaperWidth(e.target.value)}
>
<option value="80">80mm</option>
<option value="58">58mm</option>
</select>
</LabeledField>
<LabeledField label={t("autoCut")} htmlFor="auto-cut">
<label className="flex h-10 items-center gap-2 text-sm">
<input
id="auto-cut"
type="checkbox"
checked={autoCut}
onChange={(e) => setAutoCut(e.target.checked)}
/>
{t("autoCut")}
</label>
</LabeledField>
</div>
</section>
<section className="space-y-4 border-t border-border/80 pt-6">
<LabeledField label={t("receiptHeader")} htmlFor="receipt-header">
<Input
id="receipt-header"
value={receiptHeader}
onChange={(e) => setReceiptHeader(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("receiptFooter")} htmlFor="receipt-footer">
<Input
id="receipt-footer"
value={receiptFooter}
onChange={(e) => setReceiptFooter(e.target.value)}
/>
</LabeledField>
<LabeledField label={t("wifiOnReceipt")} htmlFor="wifi-pass">
<Input
id="wifi-pass"
value={wifiPassword}
onChange={(e) => setWifiPassword(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
</section>
<section className="space-y-4 rounded-lg border border-border/80 bg-muted/20 p-4 sm:p-5">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("posDeviceSection")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">{t("posDeviceHint")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("posDeviceIp")} htmlFor="pos-device-ip">
<Input
id="pos-device-ip"
value={posDeviceIp}
onChange={(e) => setPosDeviceIp(e.target.value)}
placeholder="192.168.1.50"
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("port")} htmlFor="pos-device-port">
<Input
id="pos-device-port"
value={posDevicePort}
onChange={(e) => setPosDevicePort(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
</div>
</section>
<div className="border-t border-border/80 pt-4">
<Button
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{t("saveSettings")}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/lib/stores/auth.store";
import { PageHeader } from "@/components/layout/page-header";
import { SettingsNav } from "@/components/settings/settings-nav";
import { SettingsAppearancePanel } from "@/components/settings/settings-appearance-panel";
import { CafeDiscoverProfilePanel } from "@/components/discover/cafe-discover-profile-panel";
import { CafePublicProfilePanel } from "@/components/discover/cafe-public-profile-panel";
import { SettingsShopPanel } from "@/components/settings/settings-shop-panel";
import { SettingsTerminalsPanel } from "@/components/settings/settings-terminals-panel";
import { SettingsPrinterPanel } from "@/components/settings/settings-printer-panel";
import { SettingsPrintTestPanel } from "@/components/settings/settings-print-test-panel";
import {
DEFAULT_SETTINGS_LEAF,
groupForLeaf,
type SettingsGroupId,
type SettingsLeafId,
} from "@/components/settings/settings-types";
const LEAF_PAGE_TITLE: Record<SettingsLeafId, string> = {
"shop-general": "nav.shopGeneral",
"shop-appearance": "nav.shopAppearance",
"shop-discover": "nav.shopDiscover",
"printer-config": "nav.printerSettings",
"print-test": "nav.printTest",
};
export function SettingsScreen() {
const t = useTranslations("settings");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const [activeLeaf, setActiveLeaf] = useState<SettingsLeafId>(DEFAULT_SETTINGS_LEAF);
const [expandedGroup, setExpandedGroup] = useState<SettingsGroupId>("shop");
const selectLeaf = (leaf: SettingsLeafId) => {
setActiveLeaf(leaf);
setExpandedGroup(groupForLeaf(leaf));
};
const toggleGroup = (group: SettingsGroupId) => {
setExpandedGroup((prev) => (prev === group ? prev : group));
const firstChild = group === "shop" ? "shop-general" : "printer-config";
if (groupForLeaf(activeLeaf) !== group) {
selectLeaf(firstChild);
}
};
if (!cafeId) return null;
const pageTitle = t(LEAF_PAGE_TITLE[activeLeaf]);
return (
<div className="space-y-6">
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex flex-col gap-6 md:flex-row md:items-start md:gap-8">
<SettingsNav
activeLeaf={activeLeaf}
expandedGroup={expandedGroup}
onSelectLeaf={selectLeaf}
onToggleGroup={toggleGroup}
/>
<div className="min-w-0 flex-1 space-y-4">
<p className="pb-0.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{pageTitle}
</p>
{activeLeaf === "shop-general" ? (
<div className="space-y-4">
<SettingsShopPanel cafeId={cafeId} />
<SettingsTerminalsPanel />
</div>
) : null}
{activeLeaf === "shop-appearance" ? (
<SettingsAppearancePanel cafeId={cafeId} />
) : null}
{activeLeaf === "shop-discover" ? (
<div className="space-y-6">
<CafeDiscoverProfilePanel cafeId={cafeId} mode="merchant" />
<CafePublicProfilePanel cafeId={cafeId} />
</div>
) : null}
{activeLeaf === "printer-config" ? (
<SettingsPrinterPanel
cafeId={cafeId}
onOpenPrintTest={() => selectLeaf("print-test")}
/>
) : null}
{activeLeaf === "print-test" ? (
<SettingsPrintTestPanel
cafeId={cafeId}
onOpenPrinterSettings={() => selectLeaf("printer-config")}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,208 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiPatch, apiPost, apiUpload, resolveMediaUrl } from "@/lib/api/client";
import {
cafeSettingsQueryKey,
useCafeSettings,
type CafeSettings,
} from "@/lib/hooks/use-cafe-settings";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
type SettingsShopPanelProps = {
cafeId: string;
};
export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
const t = useTranslations("settings");
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [city, setCity] = useState("");
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [coverImageUrl, setCoverImageUrl] = useState("");
const [snappfoodVendorId, setSnappfoodVendorId] = useState("");
const { data: cafeSettings } = useCafeSettings(cafeId);
useEffect(() => {
if (!cafeSettings) return;
setName(cafeSettings.name ?? "");
setCity(cafeSettings.city ?? "");
setPhone(cafeSettings.phone ?? "");
setAddress(cafeSettings.address ?? "");
setDescription(cafeSettings.description ?? "");
setLogoUrl(cafeSettings.logoUrl ?? "");
setCoverImageUrl(cafeSettings.coverImageUrl ?? "");
setSnappfoodVendorId(cafeSettings.snappfoodVendorId ?? "");
}, [cafeSettings]);
const saveProfile = useMutation({
mutationFn: () =>
apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
name,
city,
phone,
address,
description,
logoUrl: logoUrl || null,
coverImageUrl: coverImageUrl || null,
snappfoodVendorId,
}),
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success(t("profile.saved"));
},
});
const uploadLogo = useMutation({
mutationFn: (file: File) =>
apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/cafe-logo`, file),
onSuccess: (data) => setLogoUrl(data.url),
});
const uploadCover = useMutation({
mutationFn: (file: File) =>
apiUpload<{ url: string }>(`/api/cafes/${cafeId}/media/cafe-cover`, file),
onSuccess: (data) => setCoverImageUrl(data.url),
});
const submitTaraz = useMutation({
mutationFn: () =>
apiPost<{ trackingCode?: string; message?: string }>(
`/api/cafes/${cafeId}/tax/taraz/submit`
),
onSuccess: (data) => notify.success(data.message ?? t("tarazQueued")),
});
const logoSrc = resolveMediaUrl(logoUrl);
return (
<div className="space-y-4">
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("profile.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-6 pb-6 pt-0">
<div className="flex flex-wrap items-center gap-4">
{logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="" className="h-16 w-16 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted text-xs text-muted-foreground">
{t("profile.logo")}
</div>
)}
<div className="flex flex-wrap gap-2">
<label className="cursor-pointer rounded-md border px-3 py-2 text-sm hover:bg-muted">
{t("profile.uploadLogo")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadLogo.mutate(f);
}}
/>
</label>
<label className="cursor-pointer rounded-md border px-3 py-2 text-sm hover:bg-muted">
{t("profile.uploadCover")}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadCover.mutate(f);
}}
/>
</label>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("profile.name")} htmlFor="cafe-name">
<Input id="cafe-name" value={name} onChange={(e) => setName(e.target.value)} />
</LabeledField>
<LabeledField label={t("profile.city")} htmlFor="cafe-city">
<Input id="cafe-city" value={city} onChange={(e) => setCity(e.target.value)} />
</LabeledField>
<LabeledField label={t("profile.phone")} htmlFor="cafe-phone">
<Input
id="cafe-phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
<LabeledField label={t("profile.address")} htmlFor="cafe-address">
<Input
id="cafe-address"
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</LabeledField>
</div>
<LabeledField label={t("profile.description")} htmlFor="cafe-description">
<textarea
id="cafe-description"
className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</LabeledField>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={saveProfile.isPending}
onClick={() => saveProfile.mutate()}
>
{t("saveProfile")}
</Button>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("snappfoodVendor")}</CardTitle>
</CardHeader>
<CardContent className="px-6 pb-6 pt-0">
<LabeledField label={t("snappfoodVendor")} htmlFor="snappfood-vendor">
<Input
id="snappfood-vendor"
value={snappfoodVendorId}
onChange={(e) => setSnappfoodVendorId(e.target.value)}
dir="ltr"
className="text-end"
/>
</LabeledField>
</CardContent>
</Card>
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("taraz")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6 pt-0">
<p className="text-sm leading-relaxed text-muted-foreground">{t("tarazHint")}</p>
<Button
variant="outline"
disabled={submitTaraz.isPending}
onClick={() => submitTaraz.mutate()}
>
{t("tarazSubmit")}
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiDelete, apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { getOrCreateTerminalId } from "@/lib/terminal";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { notify } from "@/lib/notify";
type TerminalsResponse = {
terminals: { terminalId: string }[];
max: number;
};
export function SettingsTerminalsPanel() {
const t = useTranslations("settings.terminals");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const qc = useQueryClient();
const thisDevice = getOrCreateTerminalId();
const { data, isLoading } = useQuery({
queryKey: ["terminals", cafeId],
queryFn: () => apiGet<TerminalsResponse>(`/api/cafes/${cafeId}/terminals`),
enabled: !!cafeId,
});
const revoke = useMutation({
mutationFn: (terminalId: string) =>
apiDelete(`/api/cafes/${cafeId}/terminals/${encodeURIComponent(terminalId)}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["terminals", cafeId] });
notify.success(t("revoked"));
},
});
const list = data?.terminals ?? [];
const max = data?.max ?? 1;
return (
<Card className="rounded-xl border border-border/80">
<CardHeader>
<CardTitle className="text-base">{t("title")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("hint", { max })}</p>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("thisDevice")}: <span className="font-mono">{thisDevice}</span>
</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("loading")}</p>
) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="space-y-2">
{list.map((row) => (
<li
key={row.terminalId}
className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 text-sm"
>
<span className="font-mono text-xs">{row.terminalId}</span>
<Button
size="sm"
variant="outline"
disabled={revoke.isPending}
onClick={() => revoke.mutate(row.terminalId)}
>
{t("revoke")}
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,40 @@
export type SettingsGroupId = "shop" | "printer";
export type SettingsLeafId =
| "shop-general"
| "shop-appearance"
| "shop-discover"
| "printer-config"
| "print-test";
export type SettingsNavGroup = {
id: SettingsGroupId;
labelKey: string;
children: { id: SettingsLeafId; labelKey: string }[];
};
export const SETTINGS_NAV: SettingsNavGroup[] = [
{
id: "shop",
labelKey: "nav.shop",
children: [
{ id: "shop-general", labelKey: "nav.shopGeneral" },
{ id: "shop-appearance", labelKey: "nav.shopAppearance" },
{ id: "shop-discover", labelKey: "nav.shopDiscover" },
],
},
{
id: "printer",
labelKey: "nav.printer",
children: [
{ id: "printer-config", labelKey: "nav.printerSettings" },
{ id: "print-test", labelKey: "nav.printTest" },
],
},
];
export const DEFAULT_SETTINGS_LEAF: SettingsLeafId = "shop-general";
export function groupForLeaf(leaf: SettingsLeafId): SettingsGroupId {
return leaf === "printer-config" || leaf === "print-test" ? "printer" : "shop";
}