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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user