الترحيل إلى تخطيط الشريط الجانبي متعدد الصفحات في لوحة تحكم SaaS باستخدام Next.js App Router
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- المشكلة: تخطيط صفحة واحدة حيث "لا شيء يمكن إيجاده" و"لا مساحة لإضافة الإعدادات" بلغ حدّه الهيكلي
- الحل: الترحيل إلى هيكل شريط جانبي + متعدد الصفحات الذي تستخدمه Supabase وVercel وLinear (5 صفحات، حوالي ساعتين من العمل)
- التقنيات: الصفحات الجديدة = مجرد إضافة مجلدات في Next.js App Router؛ شريط جانبي لاصق متعدد الطبقات (
top-14+h-[calc(100vh-3.5rem)])؛ اكتشاف الحالة النشطة بـusePathname؛ العناصر "قريبًا" باستخدام<div> + aria-disabled؛ وقاموس ترجمة واحد مشترك كمصدر أحادي للحقيقة بين صفحة الهبوط ولوحة التحكم- المقايضات: تصميم درج الشريط الجانبي على الجوّال، وإدارة الحالة الموزّعة، وتكلفة تنفيذ أعلى مقدمًا للصفحات الجديدة (مناقشة أدناه)
لماذا كل SaaS يستخدم تخطيط الشريط الجانبي + متعدد الصفحات؟
Supabase وVercel وLinear وNotion وStripe Dashboard — كل منتج SaaS رئيسي يستخدم تخطيط شريط جانبي أيسر + متعدد الصفحات. إنه الحل القياسي لاحتياجين متنافسين: تقليل تكلفة التنقل وترك مساحة هيكلية للميزات المستقبلية.
نهج "أكدس كل شيء هنا" في صفحة واحدة يصلح لفترة محدودة فقط. بمجرد تجاوزك ثلاث ميزات مميزة، تبدأ الشقوق الهيكلية في الظهور. HeatMapX اصطدمت بهذا الحاجز.
الخلفية: حدود تخطيط الصفحة الواحدة
لفترة طويلة، عاشت لوحة تحكم HeatMapX بالكامل على صفحة /dashboard واحدة:
- شريط الإعلانات
- بطاقة استخدام الخطة (PlanCard)
- قائمة المواقع المسجّلة
- دليل تثبيت CLI (CliOnboarding)
- دليل "البدء"
- نوافذ حوارية متعددة (OnboardingModal وUpgradeDialog وAddSiteDialog)
كل شيء متراكم رأسيًا. جيد في البداية، لكن مع تراكم الميزات، أصبحت مشكلتان مستحيلتَي التجاهل: "لا أعرف أين أجد أي شيء"، و"لا مكان نظيف لإضافة الإعدادات". أصبح هيكل الصفحة الواحدة طريقًا مسدودًا هيكليًا.
الحل: الترحيل إلى تخطيط الشريط الجانبي الأيسر + متعدد الصفحات الذي تستخدمه Supabase وVercel وLinear.
هيكل المسارات النهائي
| المسار | المحتوى |
|---|---|
/dashboard |
الرئيسية (نظرة عامة: إحصائيات + الاستخدام + إجراءات سريعة) |
/dashboard/sites |
قائمة الخرائط الحرارية (قائمة المواقع) |
/dashboard/cli |
CLI والمهارات (إعداد CLI + دليل مهارة Claude Code) |
/dashboard/billing |
الخطة والفوترة (ملخص الاستخدام الحالي + جدول مقارنة الخطط الكامل) |
/dashboard/settings |
الإعدادات (اختيار اللغة وما إلى ذلك) |
لماذا يجعل Next.js App Router هذا سهلًا؟
مع App Router، هيكل المجلدات هو هيكل URL. إضافة صفحة جديدة تعني إنشاء مجلد وملف page.tsx. هذا كل شيء:
src/app/dashboard/
├── layout.tsx ← مشترك عبر كل dashboard/* (شريط جانبي + رأس)
├── page.tsx ← /dashboard (الرئيسية)
├── sites/
│ └── page.tsx ← /dashboard/sites
├── cli/
│ └── page.tsx ← /dashboard/cli
├── billing/
│ └── page.tsx ← /dashboard/billing
├── settings/
│ └── page.tsx ← /dashboard/settings
└── components/
├── Sidebar.tsx
├── PricingPlans.tsx
├── LanguagePicker.tsx
└── SkillOnboarding.tsx
5 صفحات جديدة = 5 ملفات جديدة + تحديث layout.tsx المشترك. التصيير من جانب الخادم (Server Components) وفحوص المصادقة (requireUser) والترجمة (useLocale) تعمل جميعها بشكل طبيعي.
رأس لاصق + شريط جانبي لاصق: التعايش بينهما
هيكل التخطيط
// dashboard/layout.tsx
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 h-14 ...">
{/* logo + theme + locale */}
</header>
<div className="flex flex-1">
<Sidebar />
<main className="flex-1">
<div className="mx-auto max-w-6xl px-8 py-8">
{children}
</div>
</main>
</div>
</div>
جعل الشريط الجانبي يلتصق أسفل الرأس
الشريط الجانبي ذو sticky top-0 سيتزلج تحت الرأس. الحل: إزاحته بارتفاع الرأس:
// dashboard/components/Sidebar.tsx
<aside className="sticky top-14 hidden h-[calc(100vh-3.5rem)] w-60 shrink-0 flex-col border-r ... sm:flex">
<nav className="flex-1 overflow-y-auto">
{/* menu items */}
</nav>
<div className="border-t">
{/* logout */}
</div>
</aside>
شيئان يجب ضبطهما بشكل صحيح:
top-14= 3.5rem = 56px (يتطابق مع ارتفاع الرأس)h-[calc(100vh-3.5rem)]يطرح الرأس من ارتفاع نافذة العرض — بدون هذا، سيتجاوز الشريط الجانبي الحد السفلي
نمط flex-col + flex-1 overflow-y-auto + border-t في الأسفل يثبّت زر تسجيل الخروج في تذييل الشريط الجانبي.
💡 الدرس الأول: عند تكديس عناصر لاصقة متعددة، احسب قيم
topبدقة.top-14(56px) وh-[calc(100vh-3.5rem)]في Tailwind زوج متطابق. إذا غيّرت ارتفاع الرأس لاحقًا، حدّث كلا القيمتين بشكل متزامن.
اكتشاف الحالة النشطة في الشريط الجانبي
استخدم usePathname لتمييز عنصر القائمة المقابل للصفحة الحالية:
'use client'
import { usePathname } from 'next/navigation'
const menuItems = [
{ href: '/dashboard', en: 'Home', ja: 'ホーム' },
{ href: '/dashboard/sites', en: 'Heatmaps', ja: 'ヒートマップ一覧' },
// ...
]
export function Sidebar() {
const pathname = usePathname() ?? '/dashboard'
return (
<aside>
{menuItems.map(item => {
// مطابقة تامة لـ /dashboard، مطابقة بالبادئة لكل شيء آخر
const isActive = item.href === '/dashboard'
? pathname === '/dashboard'
: pathname.startsWith(item.href)
return (
<Link href={item.href} className={
isActive
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}>
...
</Link>
)
})}
</aside>
)
}
/dashboard يستخدم المساواة الصارمة (===)؛ كل شيء آخر يستخدم startsWith. هذا يعني أن صفحة تفصيلية مثل /dashboard/sites/abc123 تُبقي "Heatmaps" مميّزة في الشريط الجانبي.
نمط "قريبًا": الإعلان عن ميزات لم تُبنَ بعد
أردنا إظهار A/B Testing وDynamic UI كـ"قريبًا" — مرئية لكن غير قابلة للنقر. الحل: استخدام <div> مع aria-disabled بدلًا من <Link>:
const comingSoonItems = [
{ en: 'A/B Testing', ja: 'A/B テスト' },
{ en: 'Dynamic UI', ja: 'ダイناميック UI' },
]
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Coming soon
</p>
{comingSoonItems.map(item => (
<div
aria-disabled="true"
className="flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-slate-400"
>
<Icon className="text-slate-300" />
<Bi en={item.en} ja={item.ja} />
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">
Soon
</span>
</div>
))}
عند إطلاق ميزة، انقل إدخالها من comingSoonItems إلى menuItems. التخطيط والأنماط تُعاد تلقائيًا.
💡 الدرس الثاني: الإعلان عن ميزات لم تُبنَ بعد يضع التوقعات ويبني الترقب. عنصران "قريبًا" في أسفل الشريط الجانبي يُخبران المستخدمين بأن "A/B Testing وDynamic UI في الطريق" — دون أي محتوى إضافي للصيانة. هذه الإشارة تصل إلى عدد أكبر من الناس مما تصله صفحة خارطة الطريق، لأنها تظهر في كل مرة يفتح فيها أحدهم التطبيق.
مشاركة بيانات التسعير بين صفحة الهبوط ولوحة التحكم: قاموس الترجمة كمصدر أحادي للحقيقة
تظهر معلومات خطة التسعير في مكانين: صفحة الهبوط على /en/pricing ولوحة التحكم على /dashboard/billing. تعريف البيانات في مكانين يعني أن تغيير السعر يتطلب تحديثين — وعاجلًا أم آجلًا سيُفوَّت أحدهما.
الحل: جعل قاموس الترجمة هو المصدر الأحادي للحقيقة. كلٌّ من صفحة الهبوط ولوحة التحكم تقرأن من نفس t(locale).pricing.plans:
// صفحة الهبوط: src/components/marketing/Pricing.tsx
const d = t(locale).pricing
{d.plans.map(plan => (
<article className="dark-theme-styles">...</article>
))}
// لوحة التحكم: src/app/dashboard/components/PricingPlans.tsx
const d = t(dashLocale).pricing
{d.plans.map(plan => (
<article className="light-theme-styles">...</article>
))}
هيكل البيانات متطابق. الأنماط فقط تختلف (داكن مقابل فاتح) وسلوك CTA (صفحة الهبوط تطلب تسجيل الدخول؛ لوحة التحكم تنتقل مباشرة إلى السداد). تحديث التسعير أصبح تغييرًا في ملف واحد.
الجلب المتوازي للبيانات مع Server Components في الصفحة الرئيسية
تحتاج الصفحة الرئيسية إلى جلب عدة قيم مجمّعة:
- الخطة الحالية للمستخدم
- عدد المواقع المسجّلة
- مشاهدات الصفحة الشهرية
- استخدام تحليل الذكاء الاصطناعي الشهري
- عدد الأحداث اليوم (عبر كل المواقع)
انتظار هذه القيم بشكل متسلسل في Server Component بطيء. استخدم Promise.all للتوازي:
export default async function DashboardHome() {
const user = await requireUser()
const [plan, siteCount, monthlyPv, aiUsage] = await Promise.all([
getUserPlan(user.id),
getSiteCount(user.id),
getMonthlyPageViews(user.id),
getMonthlyUsage(user.id),
])
// ...
}
هذا يخفض وقت التحميل من حوالي 1.5 ثانية إلى ~600ms. بما أن Next.js يحلّ كل الانتظارات قبل تصيير Server Component، فإن التصميم مع التوازي في الاعتبار يؤتي ثماره فورًا.
أشرطة استخدام مرمّزة بالألوان للإشارة إلى "الخطر"
تتغير ألوان شريط التقدم ديناميكيًا بناءً على نسبة الاستخدام:
function UsageCard({ used, max }) {
const pct = Math.min(100, (used / max) * 100)
const isOver = pct >= 100
const isHigh = pct >= 80
return (
<div className="h-1.5 w-full rounded-full bg-slate-200">
<div
className={
isOver ? 'bg-red-500' :
isHigh ? 'bg-amber-500' :
'bg-orange-500'
}
style={{ width: `${pct}%` }}
/>
</div>
)
}
مع اقتراب المستخدمين من حصتهم، يدفعهم تغيير اللون نحو الترقية. استخدام orange-500 كلون "طبيعي" يُبقي ألوان العلامة التجارية لـ HeatMapX مرئية في كل الأوقات.
نقل تسجيل الخروج من الرأس إلى تذييل الشريط الجانبي
في التنفيذ الأولي كان رابط تسجيل الخروج في الزاوية العليا اليمنى من الرأس. مع وجود الشريط الجانبي، انتقل إلى القسم السفلي من الشريط الجانبي. الأسباب:
- تذييل الشريط الجانبي هو المكان القياسي للإجراءات "المهمة لكن النادرة" — Slack وDiscord وNotion كلها تفعل ذلك
- يبقى الرأس مركّزًا على الضوابط العامة (السمة واللغة) والعلامة التجارية
- يقلل خطر تسجيل الخروج العرضي (الزاوية العليا اليمنى منطقة نقر عالية الحركة)
إعداد مهارة Claude Code
HeatMapX منشورة كإضافة لـ Claude Code، لذا احتاجت صفحة /dashboard/cli إلى عرض كلٍّ من CLI والمهارة جنبًا إلى جنب. تخطيط عمودين على الشاشات الأكبر:
// dashboard/cli/page.tsx
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CliOnboarding /> {/* npm install -g heatmapx */}
<SkillOnboarding /> {/* /plugin install heatmapx-skill@... */}
</div>
SkillOnboarding يعرض أوامر نموذجية بالإنجليزية والعربية:
// English example
"Analyze /pricing with HeatMapX and give me CRO ideas."
// Japanese example
"HeatMapX で /pricing を分析して、改善案を提案して。"
بهذه الطريقة، يجد كلٌّ من مستخدمي CLI ومستخدمي Claude Code Skill مسار الإعداد الخاص بهم من نفس الصفحة.
خطوات الترحيل
- إنشاء مكوّن Sidebar (5 عناصر تنقل + 2 عناصر "قريبًا"، أيقونات SVG مضمّنة)
- إعادة هيكلة layout.tsx إلى تخطيط flex (رأس + شريط جانبي + محتوى رئيسي)
- استخراج قائمة المواقع إلى /dashboard/sites (نقل محتوى page.tsx إلى sites/page.tsx)
- إعادة كتابة /dashboard كشاشة رئيسية جديدة (بطاقات إحصائيات + أشرطة استخدام + إجراءات سريعة)
- إضافة صفحات billing / cli / settings (3 ملفات page.tsx جديدة)
- إنشاء مكوّن PricingPlans (بيانات مشتركة مع صفحة الهبوط، أنماط مختلفة)
- إنشاء مكوّن SkillOnboarding (دليل مهارة Claude Code)
- إنشاء مكوّن LanguagePicker (محدد بأسلوب الاختيار للصفحة إعدادات)
الإجمالي: 4 مكوّنات جديدة + 5 ملفات page.tsx جديدة + تحديثات لـ layout.tsx وroot page.tsx = 11 ملفًا. أُنجز في جلسة Claude Code واحدة، حوالي ساعتين.
المقايضات: عيوب تخطيط الشريط الجانبي
في سبيل منح هذا التقرير مصداقية حقيقية، إليك العيوب الصادقة.
- معالجة الجوّال: أسفل نقطة توقف
sm:، يختفي الشريط الجانبي وتحتاج إلى قائمة hamburger في الرأس. في وقت كتابة هذا المقال هذا غير مكتمل — على الجوّال يختفي الشريط الجانبي ببساطة، وهي حالة وسيطة خشنة. - إدارة الحالة الموزّعة: مع توزيع البيانات على 5 صفحات، يُجلب القيم المشتركة مثل "عدد المواقع" أو "الخطة الحالية" بشكل مستقل في كل صفحة. مع Server Components تكلفة الزمن صغيرة، لكن إذا كنت تعتمد على حالة العميل، ستحتاج إلى نهج أكثر تحكمًا.
- تكلفة تنفيذ أعلى مقدمًا: صفحة واحدة كانت تعني ملفًا واحدًا. خمس صفحات مع تخطيط مشترك تعني 11 ملفًا. عند عدد ميزات منخفض، هذا مبالغة واضحة.
- مشكلة "ما الذي يذهب على الشاشة الرئيسية؟": تخطيط الصفحة الواحدة يتجاوز هذا كليًا — فقط اعرض كل شيء. تحديد ما ينتمي إلى شاشة النظرة العامة قرار تصميمي أدقّ مما يبدو.
- إدارة URL متوافقة مع الإصدار القديم: صمّمنا
/dashboardليستمر كشاشة رئيسية، حتى لا تُكسَر الإشارات المرجعية الموجودة. لكن إذا أُعيد تنظيم المسارات الفرعية مثل/dashboard/cliمستقبلًا، ستحتاج إلى إعداد تحويلات 301 بشكل صحيح.
رأينا: هذه تكاليف تستحق الدفع بمجرد أن يكون لديك أكثر من ثلاث ميزات مميزة. والعكس صحيح بالقدر ذاته — إذا كان منتجك لا يزال في مراحله الأولى وخفيف الميزات، فإن تخطيط الصفحة الواحدة هو الاختيار الصحيح على الأرجح.
الخلاصة
يمنحك Next.js App Router هيكلًا قويًا لعمليات الترحيل متعددة الصفحات — الصفحات الجديدة مجرد مجلدات جديدة. الأشرطة الجانبية اللاصقة تتعايش مع الرؤوس اللاصقة عبر top-14 + h-[calc(100vh-3.5rem)]. الحالة النشطة فحص usePathname بسيط. عناصر "قريبًا" هي <div> + aria-disabled. ومشاركة قاموس ترجمة واحد كمصدر أحادي للحقيقة بين صفحة الهبوط ولوحة التحكم يمنع حوادث تحديث التسعير.
مجتمعةً، هذه الأنماط تجعل الانتقال من "صفحة تُكدّس كل شيء" إلى "واجهة SaaS متعددة المستويات قابلة للتطوير بأسلوب Supabase" بلا عوائق تقريبًا لإضافة صفحات جديدة. ذلك قيل، المقايضات المذكورة أعلاه حقيقية — مما يعني أن اللحظة المناسبة للترحيل هي حين تتجاوز ثلاث ميزات مميزة، لا قبل ذلك.