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,52 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
type Branch = { id: string; name: string };
type BranchFilterSelectProps = {
value: string | null;
onChange: (branchId: string | null) => void;
includeAll?: boolean;
className?: string;
};
export function BranchFilterSelect({
value,
onChange,
includeAll = true,
className,
}: BranchFilterSelectProps) {
const t = useTranslations("tables");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
if (!cafeId || branches.length === 0) return null;
if (!includeAll && branches.length <= 1) return null;
return (
<select
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
aria-label={t("branchFilter")}
>
{includeAll ? (
<option value="">{t("allBranches")}</option>
) : null}
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
type Branch = { id: string; name: string };
export function BranchSelect({ className }: { className?: string }) {
const t = useTranslations("branches");
const cafeId = useAuthStore((s) => s.user?.cafeId);
const branchId = useBranchStore((s) => s.branchId);
const setBranchId = useBranchStore((s) => s.setBranchId);
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
useEffect(() => {
if (branches.length === 0) return;
const valid = branchId && branches.some((b) => b.id === branchId);
if (!valid) setBranchId(branches[0]!.id);
}, [branches, branchId, setBranchId]);
if (!cafeId || branches.length <= 1) return null;
return (
<select
className={className ?? "rounded-md border border-input bg-background px-3 py-2 text-sm"}
value={branchId ?? ""}
onChange={(e) => setBranchId(e.target.value || null)}
aria-label={t("label")}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
);
}
@@ -0,0 +1,111 @@
"use client";
import { useMemo } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import { format } from "date-fns-jalali";
import { Wifi, WifiOff } from "lucide-react";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useLiveClock } from "@/lib/hooks/use-live-clock";
import { useOnlineStatus } from "@/lib/hooks/use-online-status";
import {
formatHeaderJalaliDate,
formatHeaderTime,
isPlanTierKey,
} from "@/lib/format-datetime";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
function HeaderDivider() {
return <div className="mx-3 h-9 w-px shrink-0 bg-border/80" aria-hidden />;
}
/** WiFi + Jalali date/time + plan — grouped at header center; clock is the middle focus. */
export function HeaderCenterCluster() {
const locale = useLocale();
const t = useTranslations("dashboard");
const tPlanNames = useTranslations("settings.plans.names");
const online = useOnlineStatus();
const now = useLiveClock();
const planTier = useAuthStore((s) => s.user?.planTier);
const time = useMemo(() => formatHeaderTime(now, locale), [now, locale]);
const jalaliDate = useMemo(
() => formatHeaderJalaliDate(now, locale),
[now, locale]
);
const planLabel = planTier
? isPlanTierKey(planTier)
? tPlanNames(planTier)
: planTier
: "—";
const planBadgeVariant =
planTier === "Business" || planTier === "Enterprise"
? "default"
: planTier === "Pro"
? "secondary"
: "outline";
return (
<div
className="pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center"
aria-live="polite"
>
<div
className="flex items-center gap-2 px-1"
title={online ? t("online") : t("offline")}
aria-label={online ? t("online") : t("offline")}
>
{online ? (
<Wifi className="h-4 w-4 text-emerald-600" aria-hidden />
) : (
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden />
)}
<span
className={cn(
"hidden text-xs font-medium whitespace-nowrap lg:inline",
online ? "text-emerald-700 dark:text-emerald-500" : "text-muted-foreground"
)}
>
{online ? t("online") : t("offline")}
</span>
</div>
<HeaderDivider />
<div className="flex min-w-[5.5rem] flex-col items-center gap-0.5 px-1 text-center tabular-nums">
<time
className="max-w-[12rem] truncate text-[11px] leading-none text-muted-foreground"
dateTime={format(now, "yyyy-MM-dd")}
dir={locale === "en" ? "ltr" : "rtl"}
>
{jalaliDate}
</time>
<time
className="text-base font-semibold leading-none tracking-tight sm:text-lg"
dateTime={format(now, "HH:mm:ss")}
dir="ltr"
>
{time}
</time>
</div>
<HeaderDivider />
<Link
href="/subscription"
className="pointer-events-auto flex flex-col items-center gap-0.5 rounded-md px-1 py-0.5 transition-colors hover:bg-accent/60"
title={t("viewSubscription")}
>
<span className="text-[10px] font-medium uppercase leading-none tracking-wide text-muted-foreground">
{t("activePlan")}
</span>
<Badge variant={planBadgeVariant} className="text-xs font-semibold">
{planLabel}
</Badge>
</Link>
</div>
);
}
@@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type PageHeaderProps = {
title: string;
subtitle?: string;
action?: ReactNode;
className?: string;
};
export function PageHeader({ title, subtitle, action, className }: PageHeaderProps) {
return (
<header
className={cn(
"mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
className
)}
>
<div className="text-start">
<h1 className="text-lg font-medium tracking-tight text-foreground">{title}</h1>
{subtitle ? (
<p className="mt-1 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{subtitle}
</p>
) : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</header>
);
}
@@ -0,0 +1,277 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing";
import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions";
import {
NAV_GROUPS,
NAV_GROUPS_STORAGE_KEY,
findNavGroupForPath,
type NavGroupDef,
type NavGroupId,
type NavItemDef,
} from "@/lib/sidebar-nav";
import { useAuthStore } from "@/lib/stores/auth.store";
import { cn } from "@/lib/utils";
type OpenGroupsState = Partial<Record<NavGroupId, boolean>>;
function readStoredOpenGroups(): OpenGroupsState {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(NAV_GROUPS_STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as OpenGroupsState;
} catch {
return {};
}
}
function buildDefaultOpenGroups(): OpenGroupsState {
const stored = readStoredOpenGroups();
const defaults: OpenGroupsState = {};
for (const g of NAV_GROUPS) {
defaults[g.id] = stored[g.id] ?? g.defaultOpen;
}
return defaults;
}
function persistOpenGroups(next: OpenGroupsState): void {
try {
localStorage.setItem(NAV_GROUPS_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
function NavLink({
item,
label,
active,
}: {
item: NavItemDef;
label: string;
active: boolean;
}) {
const Icon = item.icon;
return (
<Link
href={item.href}
className={cn(
"group flex items-center rounded-lg px-3 py-2 text-sm transition-colors cursor-pointer",
active
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0 me-2.5",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)}
/>
<span className="min-w-0 truncate">{label}</span>
</Link>
);
}
function NavGroupSection({
group,
title,
open,
onToggle,
pathname,
role,
branchId,
tItem,
}: {
group: NavGroupDef;
title: string;
open: boolean;
onToggle: () => void;
pathname: string;
role: string | undefined;
branchId: string | null | undefined;
tItem: (key: string) => string;
}) {
const visibleItems = group.items.filter((item) =>
canSeeNavItem(item.key, role, branchId)
);
if (visibleItems.length === 0) return null;
return (
<div className="mb-1">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50 cursor-pointer"
>
<ChevronDown
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform duration-200",
open && "rotate-180"
)}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/70">
{title}
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-200",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="space-y-0.5 pb-1 pt-0.5">
{visibleItems.map((item) => {
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<NavLink
key={item.key}
item={item}
label={tItem(item.key)}
active={active}
/>
);
})}
</div>
</div>
</div>
</div>
);
}
export function Sidebar({ side }: { side: "left" | "right" }) {
const t = useTranslations("nav");
const tGroups = useTranslations("nav.groups");
const tBrand = useTranslations("brand");
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const hasHydrated = useAuthStore((s) => s._hasHydrated);
const role = user?.role;
const branchId = user?.branchId ?? null;
const [openGroups, setOpenGroups] = useState<OpenGroupsState>(buildDefaultOpenGroups);
const visibleGroups = useMemo(
() =>
NAV_GROUPS.filter((g) => {
if (!canSeeNavGroup(g.id, role, branchId)) return false;
return g.items.some((item) => canSeeNavItem(item.key, role, branchId));
}),
[role, branchId]
);
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
setOpenGroups((prev) => {
const next = { ...prev, [groupId]: open };
persistOpenGroups(next);
return next;
});
}, []);
useEffect(() => {
const activeGroup = findNavGroupForPath(pathname);
if (!activeGroup) return;
setOpenGroups((prev) => {
if (prev[activeGroup]) return prev;
const next = { ...prev, [activeGroup]: true };
persistOpenGroups(next);
return next;
});
}, [pathname]);
return (
<aside
className={cn(
"flex w-56 shrink-0 flex-col bg-background",
"border-border",
side === "right" ? "border-s" : "border-e"
)}
>
{/* Logo */}
<div className="flex h-14 items-center gap-2.5 px-4 border-b border-border">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-primary" aria-hidden>
<path d="M3 6h18v2H3V6zm2 4h14v2H5v-2zm-2 4h18v2H3v-2zm4 4h10v2H7v-2z" />
</svg>
</div>
<span className="text-sm font-bold tracking-tight text-foreground">
{tBrand("name")}
</span>
</div>
{/* Nav */}
<nav
className="flex-1 overflow-y-auto p-3 space-y-1
[&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:rounded-full
[&::-webkit-scrollbar-thumb]:bg-border"
aria-label={t("aria")}
>
{!hasHydrated ? (
/* Skeleton — shown for ~50ms until Zustand rehydrates from localStorage.
Prevents the flash where all groups are briefly visible before
permission-based filtering kicks in for branch-scoped accounts. */
<div className="space-y-3 px-1 pt-1">
{[40, 32, 40, 32, 40].map((w, i) => (
<div key={i} className="space-y-1.5">
<div className="h-2 w-20 animate-pulse rounded bg-muted" />
{Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => (
<div
key={j}
className={`h-8 animate-pulse rounded-lg bg-muted`}
style={{ width: `${w + j * 4}%` }}
/>
))}
</div>
))}
</div>
) : (
visibleGroups.map((group) => {
const isOpen = openGroups[group.id] ?? group.defaultOpen;
return (
<NavGroupSection
key={group.id}
group={group}
title={tGroups(group.id)}
open={isOpen}
onToggle={() => setGroupOpen(group.id, !isOpen)}
pathname={pathname}
role={role}
branchId={branchId}
tItem={(key) => t(key)}
/>
);
})
)}
</nav>
{/* Footer — user role badge */}
{user && (
<div className="border-t border-border px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10">
<span className="text-[11px] font-semibold text-primary">
{(user.actor ?? user.role).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-foreground">
{user.actor ?? user.userId}
</p>
<p className="truncate text-[10px] text-muted-foreground">{user.role}</p>
</div>
</div>
</div>
)}
</aside>
);
}
@@ -0,0 +1,102 @@
"use client";
import { WifiOff, CloudUpload, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { useLocale } from "next-intl";
import {
getAllQueueItems,
getQueueCount,
removeQueueItem,
markQueueItemFailed,
} from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client";
/** Manual retry — fires one sync pass immediately (used as onClick). */
async function runManualSync(
setSyncing: (v: boolean) => void,
setQueueCount: (n: number) => void
) {
if (!navigator.onLine) return;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
await removeQueueItem(item.id);
} catch {
await markQueueItemFailed(item.id);
}
}
} finally {
setSyncing(false);
setQueueCount(await getQueueCount());
}
}
export function SyncStatusIndicator() {
const { queueCount, isSyncing, isOnline, setSyncing, setQueueCount } =
useSyncQueueStore();
const locale = useLocale();
const isFa = locale !== "en";
const show = !isOnline || queueCount > 0 || isSyncing;
if (!show) return null;
const label = isFa
? !isOnline
? "آفلاین"
: isSyncing
? "همگام‌سازی..."
: `${queueCount} مورد در صف`
: !isOnline
? "Offline"
: isSyncing
? "Syncing..."
: `${queueCount} pending`;
return (
<button
type="button"
onClick={() => void runManualSync(setSyncing, setQueueCount)}
disabled={isSyncing || !isOnline}
title={
isFa
? "برای همگام‌سازی دستی کلیک کنید"
: "Click to retry sync"
}
className={cn(
"flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors",
"disabled:cursor-not-allowed",
!isOnline
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
: isSyncing
? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300"
)}
>
{!isOnline ? (
<WifiOff className="h-3 w-3 shrink-0" aria-hidden />
) : isSyncing ? (
<RefreshCw className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
) : (
<CloudUpload className="h-3 w-3 shrink-0" aria-hidden />
)}
<span>{label}</span>
</button>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { Link, useRouter, usePathname } from "@/i18n/routing";
import { Pencil, LogOut } from "lucide-react";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useCafeSettings } from "@/lib/hooks/use-cafe-settings";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HeaderCenterCluster } from "@/components/layout/header-center-cluster";
import { NotificationCenter } from "@/components/notifications/notification-center";
import { SyncStatusIndicator } from "@/components/layout/sync-status-indicator";
const locales = ["fa", "ar", "en"] as const;
export function Topbar() {
const t = useTranslations();
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const clearAuth = useAuthStore((s) => s.clearAuth);
const cafeId = useAuthStore((s) => s.user?.cafeId);
const { data: cafeSettings, isLoading, isPending } = useCafeSettings(cafeId);
const cafeDisplayName = cafeSettings?.name ?? t("dashboard.cafeName");
const showNameSkeleton = (isLoading || isPending) && !cafeSettings;
const switchLocale = (next: string) => {
router.replace(pathname, { locale: next });
};
return (
<header className="relative flex h-14 items-center gap-3 border-b border-border bg-background px-4 sm:px-6">
{/* Cafe name */}
<div className="flex min-w-0 flex-1 items-center gap-2">
{showNameSkeleton ? (
<Skeleton className="h-5 w-32 max-w-full" />
) : (
<Link
href="/settings"
className="group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-lg px-2 py-1 transition-colors hover:bg-accent cursor-pointer"
title={t("dashboard.editCafeSettings")}
>
<h1 className="truncate text-sm font-semibold text-foreground sm:text-base">
{cafeDisplayName}
</h1>
<Pencil
className="h-3 w-3 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-primary"
aria-hidden
/>
<span className="sr-only">{t("dashboard.editCafeSettings")}</span>
</Link>
)}
</div>
<HeaderCenterCluster />
{/* Actions */}
<div className="flex flex-1 items-center justify-end gap-1.5">
<SyncStatusIndicator />
<NotificationCenter />
{/* Language switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2.5 text-xs cursor-pointer">
{t(`languages.${locale}`)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
{locales.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => switchLocale(code)}
className={locale === code ? "font-semibold text-primary cursor-pointer" : "cursor-pointer"}
>
{t(`languages.${code}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Logout */}
<Button
variant="ghost"
size="sm"
onClick={() => {
clearAuth();
router.push("/login");
}}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive cursor-pointer"
title={t("common.logout")}
>
<LogOut className="h-3.5 w-3.5" aria-hidden />
<span className="sr-only">{t("common.logout")}</span>
</Button>
</div>
</header>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { Clock, X, Zap } from "lucide-react";
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
const STORAGE_KEY = "meezi_trial_banner_v1";
interface TimeLeft {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function calcTimeLeft(): TimeLeft {
const diff = Math.max(0, DEADLINE.getTime() - Date.now());
return {
days: Math.floor(diff / 86_400_000),
hours: Math.floor((diff % 86_400_000) / 3_600_000),
minutes: Math.floor((diff % 3_600_000) / 60_000),
seconds: Math.floor((diff % 60_000) / 1_000),
};
}
function pad(n: number) {
return n.toString().padStart(2, "0");
}
export function TrialCountdownBanner() {
const locale = useLocale();
const router = useRouter();
const isRtl = locale !== "en";
// Start hidden — reveal after mount so we can read localStorage without SSR mismatch
const [visible, setVisible] = useState(false);
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calcTimeLeft);
const [expired, setExpired] = useState(false);
// Hydrate visibility from localStorage
useEffect(() => {
if (localStorage.getItem(STORAGE_KEY) !== "1") {
setVisible(true);
}
}, []);
// Tick every second
useEffect(() => {
if (!visible) return;
const id = setInterval(() => {
const tl = calcTimeLeft();
setTimeLeft(tl);
if (tl.days === 0 && tl.hours === 0 && tl.minutes === 0 && tl.seconds === 0) {
setExpired(true);
}
}, 1_000);
return () => clearInterval(id);
}, [visible]);
if (!visible) return null;
const dismiss = () => {
setVisible(false);
localStorage.setItem(STORAGE_KEY, "1");
};
const urgency = timeLeft.days <= 3; // red when ≤ 3 days left
const soon = timeLeft.days <= 7; // amber when ≤ 7 days left
const bgClass = urgency
? "bg-red-600"
: soon
? "bg-amber-500"
: "bg-[#0F6E56]";
const textFa = expired
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
const textEn = expired
? "Your Meezi trial has ended. Choose a plan to continue."
: "Free trial ends 14 Khordad 1405 (Jun 4)";
const Digit = ({ value, label }: { value: number; label: string }) => (
<div className="flex flex-col items-center">
<span className="min-w-[2.25rem] rounded-md bg-white/20 px-2 py-0.5 text-center text-base font-extrabold tabular-nums leading-tight text-white sm:text-lg">
{pad(value)}
</span>
<span className="mt-0.5 text-[10px] font-medium text-white/70">{label}</span>
</div>
);
const labelsFa = ["روز", "ساعت", "دقیقه", "ثانیه"];
const labelsEn = ["d", "h", "m", "s"];
const labels = isRtl ? labelsFa : labelsEn;
return (
<div
className={`relative flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 sm:px-6 ${bgClass} transition-colors duration-700`}
role="banner"
aria-live="polite"
>
{/* Icon + message */}
<div className="flex items-center gap-2 text-white">
<Clock className="h-4 w-4 shrink-0 opacity-80" />
<span className="text-xs font-semibold sm:text-sm">
{isRtl ? textFa : textEn}
</span>
</div>
{/* Countdown digits */}
{!expired && (
<div className="flex items-end gap-2">
<Digit value={timeLeft.days} label={labels[0]} />
<span className="mb-3 text-white/60 font-bold">:</span>
<Digit value={timeLeft.hours} label={labels[1]} />
<span className="mb-3 text-white/60 font-bold">:</span>
<Digit value={timeLeft.minutes} label={labels[2]} />
<span className="mb-3 text-white/60 font-bold">:</span>
<Digit value={timeLeft.seconds} label={labels[3]} />
</div>
)}
{/* CTA */}
<button
onClick={() => router.push("/subscription")}
className="ms-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-gray-900 shadow-sm transition hover:bg-gray-100 active:scale-95"
>
<Zap className="h-3.5 w-3.5 text-amber-500" />
{isRtl ? "ارتقا به پرو" : "Upgrade to Pro"}
</button>
{/* Dismiss */}
<button
onClick={dismiss}
className="shrink-0 rounded p-0.5 text-white/70 transition hover:text-white"
aria-label={isRtl ? "بستن" : "Dismiss"}
>
<X className="h-4 w-4" />
</button>
</div>
);
}