From f33c61365d4d650d9066324a896010a144d3db4e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 14 Jul 2026 15:25:10 +0330 Subject: [PATCH] refactor(settings): unify settings sidebar across all pages SettingsLayout rendered its own role-gated aside while the subscription page rendered PurchaseSubscriptionSidebar, so /admin/subscription and other settings pages (sms-wallet, ...) showed two different settings menus. Make SettingsLayout render the shared PurchaseSubscriptionSidebar and have SubscriptionPage use SettingsLayout too, so every settings page shows one identical menu. menuForRole / SETTINGS_MENU are kept for the mobile settings list (SettingsMenuPage). Co-Authored-By: Claude Opus 4.8 --- .../components/layout/SettingsLayout.test.tsx | 32 ++--- .../components/layout/SettingsLayout.tsx | 118 +++--------------- assets/admin/pages/SubscriptionPage.tsx | 12 +- 3 files changed, 35 insertions(+), 127 deletions(-) diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx index ca639536..63450829 100644 --- a/assets/admin/components/layout/SettingsLayout.test.tsx +++ b/assets/admin/components/layout/SettingsLayout.test.tsx @@ -9,15 +9,13 @@ beforeEach(() => { }); describe('SettingsLayout', () => { - it('renders every menu item available to the role, plus the content', () => { + it('renders the shared settings sidebar and the content', () => { renderWithProviders(
محتوای اشتراک
, ); - for (const item of menuForRole('doctor')) { - expect(screen.getByText(item.label)).toBeInTheDocument(); - } + expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); expect(screen.getByText('محتوای اشتراک')).toBeInTheDocument(); }); @@ -28,23 +26,18 @@ describe('SettingsLayout', () => { expect(active).toHaveAttribute('href', '/admin/subscription'); }); - it('renders every visible item as a navigable link', () => { + it('renders the tauri-sourced items as navigable links', () => { renderWithProviders(
); expect(screen.getByText('مدیریت نوبت دهی').closest('a')).toHaveAttribute('href', '/admin/appointment-settings'); - expect(screen.getByText('برچسب‌ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings'); - expect(screen.queryByText('به‌زودی')).not.toBeInTheDocument(); + expect(screen.getByText('تگ ها').closest('a')).toHaveAttribute('href', '/admin/tags-settings'); + // 'مدیریت پزشک' lives in the main nav, not the settings menu + expect(screen.queryByText('مدیریت پزشک')).not.toBeInTheDocument(); }); - it('hides role-specific items from other roles', () => { - // doctor sees مدیریت پزشک but not مدیریت مطب (clinic-only) + it('shows the same menu to every role (ungated, mirroring tauri)', () => { + // the desktop sidebar is not role-gated: مدیریت مطب shows even for a doctor renderWithProviders(
); - expect(screen.getByText('مدیریت پزشک')).toBeInTheDocument(); - expect(screen.queryByText('مدیریت مطب')).not.toBeInTheDocument(); - - // clinic sees مدیریت مطب but not مدیریت پزشک / نوبت دهی - expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic'); - expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor'); - expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment'); + expect(screen.getByText('مدیریت مطب')).toBeInTheDocument(); }); it('filters the menu by the search query', () => { @@ -53,4 +46,11 @@ describe('SettingsLayout', () => { expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); expect(screen.queryByText('خدمات')).not.toBeInTheDocument(); }); + + it('menuForRole still role-gates the mobile settings list', () => { + // SettingsMenuPage (mobile) keeps using menuForRole / SETTINGS_MENU + expect(menuForRole('clinic').map((i) => i.key)).toContain('clinic'); + expect(menuForRole('clinic').map((i) => i.key)).not.toContain('doctor'); + expect(menuForRole('clinic').map((i) => i.key)).not.toContain('appointment'); + }); }); diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 82cd3c48..b914345d 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -1,16 +1,15 @@ -import React, { useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useAuthStore } from '../../stores/authStore'; +import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, WrenchScrewdriverIcon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, MagnifyingGlassIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, } from '@heroicons/react/24/outline'; +import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; // ── Settings menu configuration ───────────────────────────────────────────── -// Single source of truth for the settings sub-navigation (desktop shell + -// mobile list). `to` = an existing admin route; items without `to` are not yet -// implemented and render as disabled placeholders ("به‌زودی"). +// Source of truth for the *mobile* settings list (SettingsMenuPage). The desktop +// shell renders the shared PurchaseSubscriptionSidebar instead, so there is a +// single settings sidebar across all settings pages (no duplicate menu). export type SettingsMenuItem = { key: string; label: string; @@ -39,109 +38,20 @@ export function menuForRole(role: string | null | undefined): SettingsMenuItem[] return SETTINGS_MENU.filter((i) => !i.roles || (role != null && i.roles.includes(role))); } -// ── Shared item styling ────────────────────────────────────────────────────── -function itemStyle(active: boolean, disabled: boolean): React.CSSProperties { - return { - display: 'flex', alignItems: 'center', gap: 10, - width: '100%', padding: '11px 14px', borderRadius: 'var(--r-sm)', - fontSize: 14, fontWeight: active ? 700 : 500, textAlign: 'right', - fontFamily: 'inherit', border: 'none', cursor: disabled ? 'not-allowed' : 'pointer', - background: active ? 'var(--accent)' : 'transparent', - color: active ? '#fff' : disabled ? 'var(--text-3)' : 'var(--text-2)', - transition: 'background .14s, color .14s', - }; -} - -function MenuRow({ item, active }: { item: SettingsMenuItem; active: boolean }) { - const Icon = item.icon; - const disabled = !item.to; - const inner = ( - <> - - {item.label} - {disabled && به‌زودی} - - ); - - if (disabled) { - return ; - } - return ( - { if (!active) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }} - onMouseLeave={(e) => { if (!active) (e.currentTarget as HTMLElement).style.background = 'transparent'; }} - > - {inner} - - ); -} - /** * SettingsLayout — presentational shell for the doctor/clinic settings area. - * Renders a right-hand settings sub-navigation menu (desktop) beside the page - * content. On mobile the menu is hidden (the standalone settings list page owns - * navigation) and the content spans full width. + * Renders the shared settings sidebar (PurchaseSubscriptionSidebar) beside the + * page content, so every settings page shows the exact same menu as the + * subscription page. Full-width so the sidebar sits flush against the main nav. * - * @param active key of the currently-open settings section (highlighted) - * @param children the section content (e.g. subscription plans) + * @param active key of the currently-open settings section (highlighted) + * @param children the section content (e.g. subscription plans) */ export default function SettingsLayout({ active, children }: { active: string; children: React.ReactNode }) { - const [query, setQuery] = useState(''); - const primaryRole = useAuthStore((s) => s.primaryRole); - const items = useMemo( - () => menuForRole(primaryRole).filter((i) => i.label.includes(query.trim())), - [query, primaryRole], - ); - return ( -
-
- {/* Settings sub-nav — desktop only */} - - - {/* Section content */} +
+
+
{children}
diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index bc0b43ee..4ba11c44 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -9,7 +9,7 @@ import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '. import { usePaymentConfig } from '../hooks/usePaymentConfig'; import { formatRial, formatNumber } from '../lib/utils'; import Modal from '../components/ui/Modal'; -import PurchaseSubscriptionSidebar from '../components/layout/PurchaseSubscriptionSidebar'; +import SettingsLayout from '../components/layout/SettingsLayout'; import { SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled, } from './subscriptionIcons'; @@ -116,10 +116,8 @@ export default function SubscriptionPage() { }; return ( -
-
- - + <> +
-
+ {/* ── Payment modal (gateway selection) ── */} )} -
+ ); }