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

Complete merchant dashboard upgrade:

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:34:12 +03:30
parent ef15fd6247
commit 131ecdbbe6
208 changed files with 37123 additions and 0 deletions
@@ -0,0 +1,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>;
}
+61
View File
@@ -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>
);
}
+10
View File
@@ -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 });
}
+253
View File
@@ -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);
}
+7
View File
@@ -0,0 +1,7 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}
+46
View File
@@ -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: "سیستم فروش حضوری",
},
],
};
}
+11
View File
@@ -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} />;
}
+29
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}