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,5 @@
|
||||
import { BranchesScreen } from "@/components/branches/branches-screen";
|
||||
|
||||
export default function BranchesPage() {
|
||||
return <BranchesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CouponsScreen } from "@/components/coupons/coupons-screen";
|
||||
|
||||
export default function CouponsPage() {
|
||||
return <CouponsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CrmScreen } from "@/components/crm/crm-screen";
|
||||
|
||||
export default function CrmPage() {
|
||||
return <CrmScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExpensesScreen } from "@/components/expenses/expenses-screen";
|
||||
|
||||
export default function ExpensesPage() {
|
||||
return <ExpensesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HrScreen } from "@/components/hr/hr-screen";
|
||||
|
||||
export default function HrPage() {
|
||||
return <HrScreen />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { InventoryScreen } from "@/components/inventory/inventory-screen";
|
||||
|
||||
export default function InventoryPage() {
|
||||
return <InventoryScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KdsScreen } from "@/components/kds/kds-screen";
|
||||
|
||||
export default function KdsPage() {
|
||||
return <KdsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { useOfflineSync } from "@/lib/offline/use-offline-sync";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
useOfflineSync(); // register online/offline listeners + load queue count
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.accessToken) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const isRtl = locale !== "en";
|
||||
|
||||
const mainColumn = (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="min-h-0 flex-1 overflow-auto p-6 bg-background">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CafeThemeProvider>
|
||||
<div
|
||||
className="flex h-screen min-h-0 overflow-hidden bg-background"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
>
|
||||
{isRtl ? (
|
||||
<>
|
||||
<Sidebar side="right" />
|
||||
{mainColumn}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sidebar side="left" />
|
||||
{mainColumn}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CafeThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MenuAdminScreen } from "@/components/menu/menu-admin-screen";
|
||||
|
||||
export default function MenuPage() {
|
||||
return <MenuAdminScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NotificationsScreen } from "@/components/notifications/notifications-screen";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <NotificationsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OverviewScreen } from "@/components/overview/overview-screen";
|
||||
|
||||
export default function HomePage() {
|
||||
return <OverviewScreen />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
import { PosScreen } from "@/components/pos/pos-screen";
|
||||
|
||||
/** Full viewport height below topbar; no page scroll — only inner panes scroll. */
|
||||
export default function PosPage() {
|
||||
return (
|
||||
<div className="-m-6 flex h-full min-h-0 overflow-hidden p-4 md:p-6">
|
||||
<Suspense fallback={null}>
|
||||
<PosScreen />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { QueueScreen } from "@/components/queue/queue-screen";
|
||||
|
||||
export default function QueuePage() {
|
||||
return <QueueScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReportsScreen } from "@/components/reports/reports-screen";
|
||||
|
||||
export default function ReportsPage() {
|
||||
return <ReportsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReservationsScreen } from "@/components/reservations/reservations-screen";
|
||||
|
||||
export default function ReservationsPage() {
|
||||
return <ReservationsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReviewsScreen } from "@/components/reviews/reviews-screen";
|
||||
|
||||
export default function ReviewsPage() {
|
||||
return <ReviewsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { SettingsScreen } from "@/components/settings/settings-screen";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SettingsScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ShiftsScreen } from "@/components/shifts/shifts-screen";
|
||||
|
||||
export default function ShiftsPage() {
|
||||
return <ShiftsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SmsScreen } from "@/components/sms/sms-screen";
|
||||
|
||||
export default function SmsPage() {
|
||||
return <SmsScreen />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { SubscriptionScreen } from "@/components/subscription/subscription-screen";
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SubscriptionScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SupportTicketDetailScreen } from "@/components/support/support-screen";
|
||||
|
||||
export default function SupportTicketPage() {
|
||||
return <SupportTicketDetailScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SupportScreen } from "@/components/support/support-screen";
|
||||
|
||||
export default function SupportPage() {
|
||||
return <SupportScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TablesScreen } from "@/components/tables/tables-screen";
|
||||
|
||||
export default function TablesPage() {
|
||||
return <TablesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TaxesScreen } from "@/components/taxes/taxes-screen";
|
||||
|
||||
export default function TaxesPage() {
|
||||
return <TaxesScreen />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
|
||||
/** Full-viewport routes (queue TV display) — auth only, no dashboard chrome. */
|
||||
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.accessToken) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh" dir={locale === "en" ? "ltr" : "rtl"}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { QueueDisplayScreen } from "@/components/queue/queue-display-screen";
|
||||
|
||||
export default function QueueDisplayPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<QueueDisplayScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PublicCafeDetailScreen } from "@/components/discover/public-cafe-detail-screen";
|
||||
|
||||
export default async function PublicCafeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <PublicCafeDetailScreen slug={slug} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PublicDiscoverScreen } from "@/components/discover/public-discover-screen";
|
||||
|
||||
export default function PublicDiscoverPage() {
|
||||
return <PublicDiscoverScreen />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Providers } from "@/components/providers";
|
||||
|
||||
/** Public consumer routes (discover) — no dashboard chrome. */
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Providers>{children}</Providers>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import localFont from "next/font/local";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { Providers } from "@/components/providers";
|
||||
import "../globals.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "../../fonts/Vazirmatn-Variable.woff2",
|
||||
variable: "--font-vazirmatn",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
const inter = localFont({
|
||||
src: "../../fonts/Inter-Variable.woff2",
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as "fa" | "ar" | "en")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
setRequestLocale(locale);
|
||||
const messages = await getMessages();
|
||||
const dir = locale === "en" ? "ltr" : "rtl";
|
||||
const fontClass =
|
||||
locale === "en"
|
||||
? inter.variable
|
||||
: vazirmatn.variable;
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir}>
|
||||
<body
|
||||
className={`${fontClass} font-sans antialiased ${
|
||||
locale === "en" ? "font-[family-name:var(--font-inter)]" : "font-[family-name:var(--font-vazirmatn)]"
|
||||
}`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { apiPost, ApiClientError } from "@/lib/api/client";
|
||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LabeledField } from "@/components/ui/labeled-field";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
const router = useRouter();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
|
||||
const [phone, setPhone] = useState("09121234567");
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const authErrorMessage = (err: unknown) => {
|
||||
if (err instanceof ApiClientError) {
|
||||
switch (err.code) {
|
||||
case "RATE_LIMITED":
|
||||
return t("rateLimited");
|
||||
case "NOT_FOUND":
|
||||
return t("notFound");
|
||||
case "SMS_FAILED":
|
||||
return t("smsFailed");
|
||||
case "INVALID_OTP":
|
||||
return t("invalidOtp");
|
||||
default:
|
||||
return err.message;
|
||||
}
|
||||
}
|
||||
return err instanceof Error ? err.message : t("title");
|
||||
};
|
||||
|
||||
const sendOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/api/auth/send-otp", { phone });
|
||||
setStep("otp");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOtp = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiPost<AuthTokenResponse>("/api/auth/verify-otp", {
|
||||
phone,
|
||||
code,
|
||||
});
|
||||
setAuth(data);
|
||||
router.push("/pos");
|
||||
} catch (e) {
|
||||
setError(authErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
|
||||
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{step === "phone" ? (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void sendOtp();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("phone")} htmlFor="login-phone">
|
||||
<Input
|
||||
id="login-phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder={t("phonePlaceholder")}
|
||||
dir="ltr"
|
||||
className="text-end"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "..." : t("sendOtp")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!loading) void verifyOtp();
|
||||
}}
|
||||
>
|
||||
<LabeledField label={t("otp")} htmlFor="login-otp">
|
||||
<Input
|
||||
id="login-otp"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder={t("otpPlaceholder")}
|
||||
maxLength={6}
|
||||
dir="ltr"
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</LabeledField>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "..." : t("verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setStep("phone")}
|
||||
>
|
||||
{t("resend")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-center text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "@/i18n/routing";
|
||||
|
||||
export default async function HomePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/pos", locale });
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Meezi brand */
|
||||
--meezi-green: 162 76% 25%;
|
||||
--meezi-green-tint: 162 52% 92%;
|
||||
--meezi-amber: 38 78% 41%;
|
||||
--meezi-danger: 0 58% 41%;
|
||||
--meezi-info: 210 82% 28%;
|
||||
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--primary: var(--meezi-green);
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 162 30% 94%;
|
||||
--secondary-foreground: 162 76% 20%;
|
||||
--muted: 210 25% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--accent: var(--meezi-green-tint);
|
||||
--accent-foreground: 162 76% 20%;
|
||||
--destructive: var(--meezi-danger);
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 24% 88%;
|
||||
--input: 214 24% 88%;
|
||||
--ring: var(--meezi-green);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-vazirmatn), var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[lang="en"] body {
|
||||
font-family: var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[lang="fa"] body,
|
||||
html[lang="ar"] body {
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sonner portal — inherit Meezi fonts (toasts render outside main tree) */
|
||||
[data-sonner-toaster],
|
||||
[data-sonner-toast],
|
||||
[data-sonner-toast] [data-title],
|
||||
[data-sonner-toast] [data-description],
|
||||
[data-sonner-toast] [data-button],
|
||||
[data-sonner-toast] [data-close-button] {
|
||||
font-family: var(--font-vazirmatn), system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
html[lang="en"] [data-sonner-toaster],
|
||||
html[lang="en"] [data-sonner-toast],
|
||||
html[lang="en"] [data-sonner-toast] [data-title],
|
||||
html[lang="en"] [data-sonner-toast] [data-description],
|
||||
html[lang="en"] [data-sonner-toast] [data-button],
|
||||
html[lang="en"] [data-sonner-toast] [data-close-button] {
|
||||
font-family: var(--font-inter), system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Per-café theme — panel + menu layout variants (data-* set by applyCafeTheme) */
|
||||
html[data-panel-style="glass"] .bg-card,
|
||||
html[data-panel-style="glass"] .theme-preview-sidebar,
|
||||
html[data-panel-style="glass"] .theme-preview-menu-card {
|
||||
background: color-mix(in srgb, hsl(var(--card)) 82%, transparent) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
html[data-panel-style="bold"] .bg-card {
|
||||
border-width: 2px;
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
}
|
||||
|
||||
html[data-panel-style="elevated"] .bg-card,
|
||||
html[data-panel-style="elevated"] .theme-preview-menu-card {
|
||||
box-shadow: 0 8px 24px hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
html[data-panel-style="outline"] .bg-card {
|
||||
background: transparent !important;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
html[data-panel-style="soft"] .bg-card {
|
||||
box-shadow: 0 2px 12px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
html[data-menu-style="compact"] .theme-preview-menu-card {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-menu-style="grid"] [data-menu-grid="true"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
html[data-menu-style="list"] .theme-preview-menu-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
html[data-density="compact"] {
|
||||
--spacing-scale: 0.85;
|
||||
}
|
||||
|
||||
html[data-density="spacious"] {
|
||||
--spacing-scale: 1.15;
|
||||
}
|
||||
|
||||
html[data-density="compact"] main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
html[data-density="spacious"] main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* QR guest menu — background textures (--qr-bg set inline from café theme) */
|
||||
[data-qr-texture] {
|
||||
background-color: var(--qr-bg, #f5f5f4);
|
||||
}
|
||||
|
||||
[data-qr-texture="none"] {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
[data-qr-texture="paper"] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
color-mix(in srgb, var(--qr-bg, #f5f5f4) 88%, #000 12%) 3px,
|
||||
color-mix(in srgb, var(--qr-bg, #f5f5f4) 88%, #000 12%) 4px
|
||||
),
|
||||
radial-gradient(ellipse 120% 80% at 50% 0%, color-mix(in srgb, var(--qr-bg) 70%, #fff 30%), var(--qr-bg));
|
||||
}
|
||||
|
||||
[data-qr-texture="linen"] {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #000 8%) 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #000 8%) 7px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #000 6%) 6px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #000 6%) 7px
|
||||
);
|
||||
}
|
||||
|
||||
[data-qr-texture="dots"] {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--qr-bg) 75%, #000 25%) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 14px 14px;
|
||||
}
|
||||
|
||||
[data-qr-texture="grid"] {
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--qr-bg) 80%, #000 20%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--qr-bg) 80%, #000 20%) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
[data-qr-texture="marble"] {
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 15% 20%, color-mix(in srgb, var(--qr-bg) 55%, #fff 45%), transparent 55%),
|
||||
radial-gradient(ellipse 60% 45% at 85% 75%, color-mix(in srgb, var(--qr-bg) 60%, #ddd 40%), transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 50%, color-mix(in srgb, var(--qr-bg) 75%, #eee 25%), transparent 60%);
|
||||
}
|
||||
|
||||
[data-qr-texture="wood"] {
|
||||
background-image: repeating-linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--qr-bg) 85%, #5c4033 15%),
|
||||
color-mix(in srgb, var(--qr-bg) 92%, #5c4033 8%) 2px,
|
||||
color-mix(in srgb, var(--qr-bg) 78%, #3e2723 22%) 4px,
|
||||
color-mix(in srgb, var(--qr-bg) 90%, #5c4033 10%) 6px
|
||||
);
|
||||
background-size: 100% 8px;
|
||||
}
|
||||
|
||||
[data-qr-texture="warm"] {
|
||||
background-image:
|
||||
radial-gradient(circle at 30% 40%, color-mix(in srgb, var(--qr-bg) 70%, #d4a574 30%) 0%, transparent 45%),
|
||||
radial-gradient(circle at 70% 60%, color-mix(in srgb, var(--qr-bg) 75%, #c9a87c 25%) 0%, transparent 40%),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #8b6914 6%) 8px,
|
||||
color-mix(in srgb, var(--qr-bg) 94%, #8b6914 6%) 9px
|
||||
);
|
||||
}
|
||||
|
||||
/* QR guest menu — themed surfaces/text (vars from buildQrThemeCssVars) */
|
||||
[data-qr-guest-menu] {
|
||||
color: var(--qr-text, #1c1917);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-surface {
|
||||
background-color: var(--qr-surface, #fff);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-muted {
|
||||
color: var(--qr-text-muted, #78716c);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-text {
|
||||
color: var(--qr-text, #1c1917);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-fill-muted {
|
||||
background-color: color-mix(in srgb, var(--qr-secondary, #e1f5ee) 45%, var(--qr-surface, #fff));
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-icon {
|
||||
color: var(--qr-primary, #0f6e56);
|
||||
}
|
||||
|
||||
[data-qr-guest-menu] .qr-border {
|
||||
border-color: color-mix(in srgb, var(--qr-primary, #0f6e56) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Texture swatches in settings appearance picker */
|
||||
.qr-texture-swatch[data-qr-texture] {
|
||||
min-height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "میزی — پنل مدیریت کافه",
|
||||
short_name: "میزی",
|
||||
description: "سیستم مدیریت کافه و رستوران میزی",
|
||||
start_url: "/fa/pos",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#0F6E56",
|
||||
orientation: "any",
|
||||
categories: ["business", "productivity"],
|
||||
lang: "fa",
|
||||
dir: "rtl",
|
||||
icons: [
|
||||
{
|
||||
src: "/icons/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icons/icon-maskable-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
screenshots: [
|
||||
{
|
||||
src: "/screenshots/pos.png",
|
||||
sizes: "1280x800",
|
||||
type: "image/png",
|
||||
form_factor: "wide",
|
||||
label: "سیستم فروش حضوری",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { QrGuestMenu } from "@/components/qr/qr-guest-menu";
|
||||
|
||||
export default function QrLandingPage() {
|
||||
const params = useParams();
|
||||
const code = typeof params.code === "string" ? params.code : "";
|
||||
|
||||
return <QrGuestMenu code={code} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
export default function QrError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh flex-col items-center justify-center gap-4 bg-[#f5f5f4] p-6 text-center"
|
||||
dir="rtl"
|
||||
>
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<h1 className="text-lg font-semibold text-foreground">خطا در بارگذاری منو</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
{error.message || "صفحه منوی میز قابل نمایش نیست."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="rounded-lg bg-[#0F6E56] px-4 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import faMessages from "../../../messages/fa.json";
|
||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||
import "../globals.css";
|
||||
|
||||
const vazirmatn = localFont({
|
||||
src: "../../fonts/Vazirmatn-Variable.woff2",
|
||||
variable: "--font-vazirmatn",
|
||||
display: "swap",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export default function QrRootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl">
|
||||
<body
|
||||
className={`${vazirmatn.variable} min-h-svh bg-transparent font-sans antialiased font-[family-name:var(--font-vazirmatn)]`}
|
||||
>
|
||||
<NextIntlClientProvider locale="fa" messages={faMessages}>
|
||||
{children}
|
||||
<MeeziToaster />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user