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