Skip to content

تطبيق الويب

تطبيق الويب العام يُقدّم على 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 الموقع مع تنقل ديناميكي ومُبدّل اللغات:

tsx
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، ثم التحقق من الكود
tsx
// 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 || "/");
};

التسجيل

صفحة التسجيل تجمع الاسم والبريد الإلكتروني وكلمة المرور وتأكيد كلمة المرور. بعد نجاح التسجيل، يُسجّل دخول المستخدم تلقائياً ويُعاد توجيهه:

tsx
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 يتحقق من حالة المصادقة. إذا لم يكن المستخدم مُصادقاً عليه، يُعاد توجيهه لصفحة تسجيل الدخول مع حفظ المسار الحالي:

tsx
// 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كلمة المرور الحالية + كلمة المرور الجديدة مع التأكيد

رفع الصورة الرمزية

صفحة تعديل الملف الشخصي تتضمن مكون رفع صورة رمزية يعرض معاينة للصورة المُختارة قبل الرفع:

tsx
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 الأمثل:

tsx
// 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 للمحتوى:

tsx
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 الخلفي. هذا يسمح لمديري الموقع بإدارة بنية التنقل من لوحة الإدارة بدون نشر تغييرات كود:

tsx
// 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. عندما يُبدّل المستخدم اللغة:

  1. يُعيّن cookies locale و direction (مشتركة عبر النطاقات الفرعية)
  2. يُحدّث localStorage كاحتياط
  3. يُعيّن document.documentElement.dir و document.documentElement.lang
  4. يُحدّث حالة سياق I18n (يُطلق إعادة عرض كل المحتوى المُترجم)
  5. يستدعي router.refresh() لتحديث المحتوى المُعروض من الخادم
tsx
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] يُعرض بالاتجاه الصحيح

لمزيد من التفاصيل، راجع التدويل.

ما التالي

Released under the MIT License.