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 });
}