diff --git a/.claude/launch.json b/.claude/launch.json index 17ad4ca..e1cb742 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,20 @@ "runtimeExecutable": "dotnet", "runtimeArgs": ["run", "--project", "F:/Projects/DrSousan/DrSousan.Api", "--urls", "http://localhost:5000"], "port": 5000 + }, + { + "name": "meezi-website", + "runtimeExecutable": "node", + "runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3013"], + "cwd": "web/website", + "port": 3013 + }, + { + "name": "meezi-dashboard", + "runtimeExecutable": "node", + "runtimeArgs": ["node_modules/next/dist/bin/next", "dev", "-p", "3015"], + "cwd": "web/dashboard", + "port": 3015 } ] } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 946465a..38a2874 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -102,12 +102,10 @@ "collapseSidebar": "طي الشريط الجانبي", "expandSidebar": "توسيع الشريط الجانبي", "groups": { - "operations": "العمليات اليومية", - "menuSales": "القائمة والمبيعات", - "customers": "العملاء", - "finance": "التقارير والمالية", + "customers": "العملاء والتسويق", "management": "إدارة المقهى" }, + "home": "لوحة التحكم", "pos": "نقطة البيع", "tables": "الطاولات", "menu": "القائمة", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 9cc160b..6f91f93 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -113,12 +113,10 @@ "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar", "groups": { - "operations": "Daily operations", - "menuSales": "Menu & sales", - "customers": "Customers", - "finance": "Reports & finance", + "customers": "Customers & marketing", "management": "Café management" }, + "home": "Dashboard", "pos": "POS", "tables": "Tables", "crm": "CRM", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index a4fb674..62801ee 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -113,12 +113,10 @@ "collapseSidebar": "جمع کردن نوار کناری", "expandSidebar": "باز کردن نوار کناری", "groups": { - "operations": "عملیات روزانه", - "menuSales": "منو و فروش", - "customers": "مشتریان", - "finance": "گزارش و مالی", + "customers": "مشتریان و بازاریابی", "management": "مدیریت کافه" }, + "home": "داشبورد", "pos": "صندوق", "tables": "میزها", "crm": "مشتریان", diff --git a/web/dashboard/src/app/[locale]/layout.tsx b/web/dashboard/src/app/[locale]/layout.tsx index 7a1ffca..cf63b45 100644 --- a/web/dashboard/src/app/[locale]/layout.tsx +++ b/web/dashboard/src/app/[locale]/layout.tsx @@ -24,6 +24,12 @@ export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } +// Cap the prerendered-HTML cache lifetime. Without this Next emits +// `s-maxage=31536000` and the WCDN edge in front of app.meezi.ir keeps serving +// year-old HTML shells (pointing at deleted JS chunks) long after a deploy. +// 5 minutes keeps pages static+fast while letting deploys go live promptly. +export const revalidate = 300; + export default async function LocaleLayout({ children, params, diff --git a/web/dashboard/src/app/[locale]/page.tsx b/web/dashboard/src/app/[locale]/page.tsx deleted file mode 100644 index 1c74d44..0000000 --- a/web/dashboard/src/app/[locale]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { redirect } from "@/i18n/routing"; - -export default async function HomePage({ - params, -}: { - params: Promise<{ locale: string }>; -}) { - const { locale } = await params; - redirect({ href: "/pos", locale }); -} diff --git a/web/dashboard/src/components/auth/route-guard.tsx b/web/dashboard/src/components/auth/route-guard.tsx index d8daa3f..9daa13e 100644 --- a/web/dashboard/src/components/auth/route-guard.tsx +++ b/web/dashboard/src/components/auth/route-guard.tsx @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { ShieldX } from "lucide-react"; import { useTranslations } from "next-intl"; import { usePathname } from "@/i18n/routing"; -import { NAV_GROUPS, type NavItemKey } from "@/lib/sidebar-nav"; +import { ALL_NAV_ITEMS, type NavItemKey } from "@/lib/sidebar-nav"; import { NAV_REQUIRED_PERMISSION } from "@/lib/permissions"; import { canSeeNavItem } from "@/lib/auth-permissions"; import { permissionsOf } from "@/lib/permissions"; @@ -12,11 +12,13 @@ import { useAuthStore } from "@/lib/stores/auth.store"; /** Resolve the nav item key that owns the given pathname (locale already stripped). */ function navKeyForPath(pathname: string): NavItemKey | null { - for (const group of NAV_GROUPS) { - for (const item of group.items) { - if (pathname === item.href || pathname.startsWith(`${item.href}/`)) { - return item.key; - } + for (const item of ALL_NAV_ITEMS) { + if (item.href === "/") { + if (pathname === "/") return item.key; + continue; + } + if (pathname === item.href || pathname.startsWith(`${item.href}/`)) { + return item.key; } } return null; diff --git a/web/dashboard/src/components/layout/sidebar.tsx b/web/dashboard/src/components/layout/sidebar.tsx index 66dc88e..ea08798 100644 --- a/web/dashboard/src/components/layout/sidebar.tsx +++ b/web/dashboard/src/components/layout/sidebar.tsx @@ -4,9 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { useTranslations } from "next-intl"; import { Link, usePathname } from "@/i18n/routing"; -import { canSeeNavGroup, canSeeNavItem } from "@/lib/auth-permissions"; +import { canSeeNavItem } from "@/lib/auth-permissions"; import { permissionsOf } from "@/lib/permissions"; import { + FOOTER_NAV, NAV_GROUPS, NAV_GROUPS_STORAGE_KEY, findNavGroupForPath, @@ -45,8 +46,8 @@ function buildDefaultOpenGroups(): OpenGroupsState { const stored = readStoredOpenGroups(); const defaults: OpenGroupsState = {}; for (const g of NAV_GROUPS) { - // Default ALL groups closed on first visit; only restore if user explicitly saved state. - defaults[g.id] = stored[g.id] ?? false; + if (g.flat) continue; // flat groups are always expanded — no state + defaults[g.id] = stored[g.id] ?? g.defaultOpen; } return defaults; } @@ -59,6 +60,11 @@ function persistOpenGroups(next: OpenGroupsState): void { } } +function isItemActive(item: NavItemDef, pathname: string): boolean { + if (item.href === "/") return pathname === "/"; + return pathname === item.href || pathname.startsWith(`${item.href}/`); +} + function NavLink({ item, label, @@ -124,24 +130,27 @@ function NavGroupSection({ ); if (visibleItems.length === 0) return null; - // Collapsed: drop the group header, show items as a flat icon-only list - // with a subtle divider between groups. - if (collapsed) { + // Flat groups (daily-use primary nav) and the collapsed-rail mode both render + // as a plain list — no header, always expanded. + if (group.flat || collapsed) { return ( -
- {visibleItems.map((item) => { - const active = - pathname === item.href || pathname.startsWith(`${item.href}/`); - return ( - - ); - })} +
+ {visibleItems.map((item) => ( + + ))}
); } @@ -173,19 +182,15 @@ function NavGroupSection({ >
- {visibleItems.map((item) => { - const active = - pathname === item.href || pathname.startsWith(`${item.href}/`); - return ( - - ); - })} + {visibleItems.map((item) => ( + + ))}
@@ -232,10 +237,14 @@ export function Sidebar({ side }: { side: "left" | "right" }) { 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, permissions)); - }), + NAV_GROUPS.filter((g) => + g.items.some((item) => canSeeNavItem(item.key, role, branchId, permissions)) + ), + [role, branchId, permissions] + ); + + const visibleFooterItems = useMemo( + () => FOOTER_NAV.filter((item) => canSeeNavItem(item.key, role, branchId, permissions)), [role, branchId, permissions] ); @@ -244,6 +253,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) { setOpenGroups((_prev) => { const next: OpenGroupsState = {}; for (const g of NAV_GROUPS) { + if (g.flat) continue; // If opening: only the clicked group becomes true; everything else closes. // If closing: just close the clicked group, leave others as-is. next[g.id] = open ? g.id === groupId : g.id === groupId ? false : (_prev[g.id] ?? false); @@ -253,7 +263,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) { }); }, []); - // When navigating to a new path, open only the group that contains that path (accordion). + // When navigating to a path inside a collapsible group, open that group. useEffect(() => { const activeGroup = findNavGroupForPath(pathname); if (!activeGroup) return; @@ -262,6 +272,7 @@ export function Sidebar({ side }: { side: "left" | "right" }) { // Accordion: open active group, close all others const next: OpenGroupsState = {}; for (const g of NAV_GROUPS) { + if (g.flat) continue; next[g.id] = g.id === activeGroup; } persistOpenGroups(next); @@ -320,29 +331,24 @@ export function Sidebar({ side }: { side: "left" | "right" }) { ))} ) : ( -
- {[40, 32, 40, 32, 40].map((w, i) => ( -
-
- {Array.from({ length: i % 2 === 0 ? 3 : 2 }).map((_, j) => ( -
- ))} -
+
+ {[88, 72, 80, 64, 76, 84, 68, 60].map((w, i) => ( +
))}
) ) : ( visibleGroups.map((group) => { - const isOpen = openGroups[group.id] ?? group.defaultOpen; + const isOpen = group.flat ? true : (openGroups[group.id] ?? group.defaultOpen); return ( setGroupOpen(group.id, !isOpen)} pathname={pathname} @@ -357,8 +363,40 @@ export function Sidebar({ side }: { side: "left" | "right" }) { )} - {/* Footer — user info + collapse toggle */} + {/* Footer — utility links + user info + collapse toggle */}
+ {/* Utility links (settings / subscription / support) — compact icon row */} + {hasHydrated && visibleFooterItems.length > 0 && ( +
+ {visibleFooterItems.map((item) => { + const Icon = item.icon; + const active = isItemActive(item, pathname); + const label = t(item.key); + return ( + + + + ); + })} +
+ )} + {/* User badge */} {user && (
| null ): boolean { - if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) { + // Branch-scoped staff only see daily-operations destinations. + if (isBranchAccount(branchId) && !BRANCH_ALLOWED_NAV_KEYS.has(key as NavItemKey)) { return false; } - if (key === "branches" && isBranchAccount(branchId)) { + if ((OWNER_ONLY_NAV_KEYS as readonly string[]).includes(key) && !isCafeOwner(role)) { return false; } // Permission-based page visibility. `permissions === null` means a legacy diff --git a/web/dashboard/src/lib/sidebar-nav.ts b/web/dashboard/src/lib/sidebar-nav.ts index 5a05029..f56a1ac 100644 --- a/web/dashboard/src/lib/sidebar-nav.ts +++ b/web/dashboard/src/lib/sidebar-nav.ts @@ -1,5 +1,6 @@ import type { LucideIcon } from "lucide-react"; import { + LayoutDashboard, LayoutGrid, UtensilsCrossed, Users, @@ -14,7 +15,6 @@ import { Receipt, Settings, ChefHat, - Bell, ListOrdered, Building2, CreditCard, @@ -24,23 +24,23 @@ import { Compass, } from "lucide-react"; -export type NavGroupId = "operations" | "menuSales" | "customers" | "finance" | "management"; +export type NavGroupId = "main" | "customers" | "management"; export type NavItemKey = + | "home" | "pos" | "tables" - | "queue" | "kds" - | "notifications" + | "queue" | "reservations" | "menu" - | "inventory" - | "coupons" + | "reports" | "crm" + | "coupons" | "sms" | "reviews" | "discover" - | "reports" + | "inventory" | "expenses" | "shifts" | "taxes" @@ -58,72 +58,86 @@ export type NavItemDef = { export type NavGroupDef = { id: NavGroupId; + /** Flat groups render without a header and are always expanded. */ + flat?: boolean; defaultOpen: boolean; items: NavItemDef[]; }; +/** + * Frequency-based IA: the flat "main" group holds everything a café touches + * daily (always visible, no clicks to reach); the two collapsible groups hold + * weekly/monthly destinations; FOOTER_NAV holds rare utility pages. + */ export const NAV_GROUPS: NavGroupDef[] = [ { - id: "operations", + id: "main", + flat: true, defaultOpen: true, items: [ + { key: "home", href: "/", icon: LayoutDashboard }, { key: "pos", href: "/pos", icon: LayoutGrid }, { key: "tables", href: "/tables", icon: UtensilsCrossed }, - { key: "queue", href: "/queue", icon: ListOrdered }, { key: "kds", href: "/kds", icon: ChefHat }, - { key: "notifications", href: "/notifications", icon: Bell }, + { key: "queue", href: "/queue", icon: ListOrdered }, { key: "reservations", href: "/reservations", icon: Calendar }, - ], - }, - { - id: "menuSales", - defaultOpen: true, - items: [ { key: "menu", href: "/menu", icon: BookOpen }, - { key: "inventory", href: "/inventory", icon: Package }, - { key: "coupons", href: "/coupons", icon: Ticket }, + { key: "reports", href: "/reports", icon: BarChart3 }, ], }, { id: "customers", - defaultOpen: true, + defaultOpen: false, items: [ { key: "crm", href: "/crm", icon: Users }, + { key: "coupons", href: "/coupons", icon: Ticket }, { key: "sms", href: "/sms", icon: MessageSquare }, { key: "reviews", href: "/reviews", icon: Star }, { key: "discover", href: "/discover", icon: Compass }, ], }, { - id: "finance", - defaultOpen: true, + id: "management", + defaultOpen: false, items: [ - { key: "reports", href: "/reports", icon: BarChart3 }, + { key: "inventory", href: "/inventory", icon: Package }, { key: "expenses", href: "/expenses", icon: Wallet }, { key: "shifts", href: "/shifts", icon: Clock }, { key: "taxes", href: "/taxes", icon: Receipt }, - ], - }, - { - id: "management", - defaultOpen: true, - items: [ { key: "hr", href: "/hr", icon: UserCog }, { key: "branches", href: "/branches", icon: Building2 }, - { key: "subscription", href: "/subscription", icon: CreditCard }, - { key: "settings", href: "/settings", icon: Settings }, - { key: "support", href: "/support", icon: LifeBuoy }, ], }, ]; -export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v4"; +/** Compact utility links rendered in the sidebar footer (outside the scroll area). */ +export const FOOTER_NAV: NavItemDef[] = [ + { key: "settings", href: "/settings", icon: Settings }, + { key: "subscription", href: "/subscription", icon: CreditCard }, + { key: "support", href: "/support", icon: LifeBuoy }, +]; -/** Branch-scoped staff only see daily operations. */ -export const BRANCH_ONLY_NAV_GROUP: NavGroupId = "operations"; +/** Every nav destination (groups + footer) — used by the route guard. */ +export const ALL_NAV_ITEMS: NavItemDef[] = [ + ...NAV_GROUPS.flatMap((g) => g.items), + ...FOOTER_NAV, +]; + +export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v5"; + +/** Branch-scoped staff only see daily-operations destinations. */ +export const BRANCH_ALLOWED_NAV_KEYS: ReadonlySet = new Set([ + "home", + "pos", + "tables", + "kds", + "queue", + "reservations", +]); export function findNavGroupForPath(pathname: string): NavGroupId | null { for (const group of NAV_GROUPS) { + if (group.flat) continue; // flat groups have no open/closed state for (const item of group.items) { if (pathname === item.href || pathname.startsWith(`${item.href}/`)) { return group.id;