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