تطبيق الويب
تطبيق الويب العام يُقدّم على myapp.test ويوفر تجربة المستخدم النهائي: صفحات تسويقية، تدفقات مصادقة، إدارة الملف الشخصي للمستخدم، صفحات محتوى ديناميكية، وتنقل متعدد اللغات. يستخدم Next.js App Router مع مزيج من Server Components (للمحتوى المُحسّن لـ SEO) و Client Components (للميزات التفاعلية).
خريطة الصفحات
myapp.test/
├── / الصفحة الرئيسية (قسم hero متعدد اللغات)
├── /login تسجيل الدخول (email/password أو mobile/OTP)
├── /register تسجيل المستخدم
├── /password/forgot نسيت كلمة المرور
├── /password/reset إعادة تعيين كلمة المرور بالـ token
├── /profile عرض الملف الشخصي (محمي)
├── /profile/edit تعديل الاسم، البريد، الصورة الرمزية (محمي)
├── /profile/password تغيير كلمة المرور (محمي)
└── /[slug] صفحات محتوى ديناميكية (من نحن، الشروط، إلخ.)مجموعات المسارات
تطبيق الويب ينظم المسارات في ثلاث مجموعات باستخدام صيغة مجموعات المسارات في Next.js:
app/
├── (auth)/ # صفحات المصادقة
│ ├── layout.tsx # تخطيط بطاقة وسطية
│ ├── login/page.tsx
│ ├── register/page.tsx
│ └── password/
│ ├── forgot/page.tsx
│ └── reset/page.tsx
├── (protected)/ # صفحات المستخدم المُصادق عليه
│ ├── layout.tsx # حارس المصادقة + إعادة التوجيه
│ └── profile/
│ ├── page.tsx
│ ├── edit/page.tsx
│ └── password/page.tsx
├── [slug]/ # صفحات محتوى ديناميكية
│ ├── page.tsx # Server Component مع SEO
│ └── not-found.tsx # 404 للمحتوى المفقود
├── layout.tsx # Root layout (providers, html dir)
└── page.tsx # الصفحة الرئيسيةالصفحة الرئيسية
الصفحة الرئيسية تعرض قسم hero مع محتوى مُترجم مسحوب من I18n provider. تتضمن header الموقع مع تنقل ديناميكي ومُبدّل اللغات:
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
export default function HomePage() {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">
<HeroSection />
{/* Additional content sections */}
</main>
<SiteFooter />
</div>
);
}قسم hero يستخدم دالة t() للنص المُترجم، مُبدّلاً تلقائياً بين اللغات عندما يُغيّر المستخدم لغته.
صفحات المصادقة
مسارات المصادقة ((auth)/)
صفحات المصادقة تشترك في تخطيط بطاقة وسطية مناسب لنماذج تسجيل الدخول والتسجيل. هذه الصفحات متاحة بدون مصادقة.
صفحة تسجيل الدخول تدعم طريقتين للمصادقة بناءً على إعدادات FORGE:
| الطريقة | الحقول | التدفق |
|---|---|---|
| البريد/كلمة المرور | البريد + كلمة المرور | تسجيل دخول مباشر مع استجابة JWT |
| الجوال/OTP | رقم الجوال + كود OTP | إرسال OTP، ثم التحقق من الكود |
// Login form submits to API and stores JWT token
const handleLogin = async (data: LoginFormData) => {
const response = await api.post<LoginResponse>("/auth/login", data);
localStorage.setItem("access_token", response.data.access_token);
router.push(redirect || "/");
};التسجيل
صفحة التسجيل تجمع الاسم والبريد الإلكتروني وكلمة المرور وتأكيد كلمة المرور. بعد نجاح التسجيل، يُسجّل دخول المستخدم تلقائياً ويُعاد توجيهه:
const handleRegister = async (data: RegisterFormData) => {
const response = await api.post<RegisterResponse>("/auth/register", data);
localStorage.setItem("access_token", response.data.access_token);
router.push("/");
};استرداد كلمة المرور
تدفق نسيت كلمة المرور يُرسل رابط إعادة تعيين لبريد المستخدم الإلكتروني. صفحة إعادة التعيين تقبل الـ token من الـ URL، تتحقق منه، وتسمح للمستخدم بتعيين كلمة مرور جديدة:
المستخدم ينقر "نسيت كلمة المرور"
│
▼
إدخال البريد الإلكتروني → POST /auth/forgot-password
│
▼
بريد برابط إعادة التعيين → /password/reset?token=xxx&email=user@example.com
│
▼
إدخال كلمة مرور جديدة → POST /auth/reset-password
│
▼
إعادة التوجيه لـ /login مع رسالة نجاحالمسارات المحمية
حارس المصادقة ((protected)/)
مجموعة المسارات المحمية تستخدم مكون layout يتحقق من حالة المصادقة. إذا لم يكن المستخدم مُصادقاً عليه، يُعاد توجيهه لصفحة تسجيل الدخول مع حفظ المسار الحالي:
// app/(protected)/layout.tsx
"use client";
import { useAuth } from "@/components/providers/auth-provider";
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isLoading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !user) {
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
}
}, [user, isLoading, pathname, router]);
if (isLoading) {
return <LoadingSkeleton />;
}
if (!user) {
return null;
}
return <>{children}</>;
}تحذير
المسارات المحمية تُعيد التوجيه من جانب العميل عبر AuthProvider. الـ API الخلفي يفرض أيضاً المصادقة على نقاط النهاية، لذا حتى لو تجاوز المستخدم حارس الواجهة الأمامية، تبقى البيانات المحمية غير متاحة.
صفحات الملف الشخصي
يمكن للمستخدمين المُصادق عليهم إدارة ملفهم الشخصي عبر ثلاث صفحات مخصصة:
| الصفحة | المسار | الميزات |
|---|---|---|
| عرض الملف الشخصي | /profile | عرض الاسم، البريد، الصورة الرمزية، معلومات الحساب |
| تعديل الملف الشخصي | /profile/edit | تحديث الاسم، البريد (يتطلب كلمة المرور)، رفع الصورة الرمزية |
| تغيير كلمة المرور | /profile/password | كلمة المرور الحالية + كلمة المرور الجديدة مع التأكيد |
رفع الصورة الرمزية
صفحة تعديل الملف الشخصي تتضمن مكون رفع صورة رمزية يعرض معاينة للصورة المُختارة قبل الرفع:
const handleAvatarUpload = async (file: File) => {
try {
const response = await api.upload<{
data: { url: string; id: string };
}>("/media", file, "file");
setAvatarUrl(response.data.url);
toast.success(t("profile.Avatar_uploaded_successfully"));
} catch (error) {
toast.error(getErrorMessage(error));
}
};نماذج الملف الشخصي تستخدم مخططات تحقق Zod و React Hook Form، متبعة نفس الأنماط الموثقة في النماذج والتحقق.
صفحات المحتوى الديناميكية
مسارات المحتوى ([slug]/)
صفحات المحتوى تستخدم مسار [slug] ديناميكي يجلب المحتوى من الـ API الخلفي. هذه Server Components لـ SEO الأمثل:
// app/[slug]/page.tsx
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
async function getContent(slug: string, locale?: string) {
const apiUrl = process.env.INTERNAL_API_URL
|| process.env.NEXT_PUBLIC_API_URL;
const url = locale
? `${apiUrl}/api/v1/contents/${slug}?locale=${locale}`
: `${apiUrl}/api/v1/contents/${slug}`;
const res = await fetch(url, {
next: { revalidate: 60 },
});
if (!res.ok) return null;
const json = await res.json();
return json.data;
}
export default async function ContentPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const headersList = await headers();
const locale = headersList.get("x-locale") || "en";
const content = await getContent(slug, locale);
if (!content) notFound();
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">
{content.featured_image_url && (
<section className="relative h-[400px] overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${content.featured_image_url})`,
}}
/>
</section>
)}
{content.details && (
<section className="container py-12">
<div className="max-w-3xl mx-auto">
{/* Render trusted HTML content from CMS */}
<div
className="prose prose-lg dark:prose-invert max-w-none"
// Security note: Content comes from trusted admin users
// For user-generated content, use DOMPurify for sanitization
/>
</div>
</section>
)}
</main>
<SiteFooter />
</div>
);
}تحذير
محتوى HTML في صفحات المحتوى الديناميكية يأتي من مستخدمي admin الموثوقين عبر محرر النص الغني في CMS. هذا المحتوى يأتي من نقاط نهاية admin مُصادق عليها، وليس من إدخال المستخدم العام. إذا وسّعت هذا النمط بمحتوى مُولّد من المستخدم، عقّم الـ HTML بمكتبة مثل DOMPurify قبل العرض.
نصيحة
صفحات المحتوى تستخدم next: { revalidate: 60 } لـ Incremental Static Regeneration. الصفحات تُولّد ثابتة عند أول طلب وتُحدّث كل 60 ثانية، مما يمنحك الأداء والتحديث معاً.
SEO
صفحات المحتوى الديناميكية تُولّد metadata كاملة من حقول SEO للمحتوى:
export async function generateMetadata({
params,
}: ContentPageProps): Promise<Metadata> {
const { slug } = await params;
const headersList = await headers();
const locale = headersList.get("x-locale") || "en";
const content = await getContent(slug, locale);
if (!content) {
return { title: "الصفحة غير موجودة" };
}
return {
title: content.seo?.title || content.title,
description: content.seo?.description || content.summary,
keywords: content.seo?.keywords,
robots: content.seo?.robots,
alternates: content.seo?.canonical
? { canonical: content.seo.canonical }
: undefined,
openGraph: {
title: content.seo?.title || content.title,
description: content.seo?.description || content.summary,
images: content.featured_image_url
? [{ url: content.featured_image_url }]
: undefined,
},
};
}محرر المحتوى في لوحة الإدارة يوفر حقولاً لجميع خصائص SEO (العنوان، الوصف، الكلمات المفتاحية، robots، الـ URL القانوني) لكل صفحة محتوى ولكل لغة.
التنقل الديناميكي
header و footer تطبيق الويب تعرض قوائم تنقل مجلوبة من Menus API الخلفي. هذا يسمح لمديري الموقع بإدارة بنية التنقل من لوحة الإدارة بدون نشر تغييرات كود:
// components/dynamic-nav.tsx
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/components/providers/i18n-provider";
import type { PublicMenu } from "@/lib/types";
export function DynamicNav() {
const { locale } = useI18n();
const [menus, setMenus] = useState<PublicMenu[]>([]);
useEffect(() => {
const fetchMenus = async () => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const res = await fetch(
`${apiUrl}/api/v1/menus?locale=${locale}`
);
if (res.ok) {
const json = await res.json();
setMenus(json.data || []);
}
};
fetchMenus();
}, [locale]);
return (
<nav className="flex items-center gap-6">
{menus.map((item) => (
<Link
key={item.id}
href={item.url || "#"}
target={item.target}
className="text-sm font-medium transition-colors hover:text-primary"
>
{item.title}
</Link>
))}
</nav>
);
}نصيحة
عناصر القائمة تدعم إعدادات visibility: public (تُعرض دائماً)، auth (تُعرض فقط للمستخدمين المُصادق عليهم)، و guest (تُعرض فقط للمستخدمين غير المُصادق عليهم). يُفرض هذا على مستوى الـ API بناءً على حالة مصادقة الطلب.
مُبدّل اللغات
تطبيق الويب يتضمن قائمة منسدلة لتبديل اللغات في الـ header. عندما يُبدّل المستخدم اللغة:
- يُعيّن cookies
localeوdirection(مشتركة عبر النطاقات الفرعية) - يُحدّث
localStorageكاحتياط - يُعيّن
document.documentElement.dirوdocument.documentElement.lang - يُحدّث حالة سياق I18n (يُطلق إعادة عرض كل المحتوى المُترجم)
- يستدعي
router.refresh()لتحديث المحتوى المُعروض من الخادم
import { Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useI18n } from "@/components/providers/i18n-provider";
export function LanguageSwitcher() {
const { locale, languages, setLocale } = useI18n();
if (languages.length <= 1) return null;
const currentLang = languages.find((l) => l.code === locale);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">
{currentLang?.native_name || "اللغة"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLocale(lang.code)}
className={locale === lang.code ? "bg-accent" : ""}
>
{lang.native_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}المكون يُخفي نفسه تلقائياً عندما تكون لغة واحدة فقط مُعدّة.
دعم RTL
تطبيق الويب يدعم بالكامل لغات من اليمين لليسار. عندما يُبدّل المستخدم للغة RTL (مثل العربية)، كل التخطيط ينعكس:
- خاصية
<html dir="rtl">تعكس اتجاه المستند - خصائص CSS المنطقية (
ms-,me-,ps-,pe-) تتكيف تلقائياً - محتوى header و footer يُعاد ترتيبه للجانب الصحيح
- محتوى النص الغني داخل صفحات
[slug]يُعرض بالاتجاه الصحيح
لمزيد من التفاصيل، راجع التدويل.
ما التالي
- لوحة التحكم الإدارية — لوحة الإدارة وصفحات CRUD
- المكونات — مكتبة المكونات المشتركة
- النماذج والتحقق — أنماط معالجة النماذج
- التدويل — دعم تعدد اللغات و RTL