إطار Next.js

التجارة الإلكترونية مع Next.js

50 دقيقة الدرس 38 من 40

التجارة الإلكترونية مع Next.js

بناء تطبيق التجارة الإلكترونية باستخدام Next.js يجمع بين فوائد الأداء للإطار مع تجارب التسوق الحديثة. يغطي هذا الدرس تنفيذ كتالوجات المنتجات، وعربات التسوق، وتدفقات الدفع، وتكامل الدفع.

نظرة عامة على بنية التجارة الإلكترونية

يتضمن تطبيق التجارة الإلكترونية الحديث في Next.js عادةً:

  • كتالوج المنتجات: تصفح وبحث المنتجات مع التصفية والترقيم
  • تفاصيل المنتج: صفحات منتج غنية بالصور والأوصاف والمتغيرات
  • عربة التسوق: إضافة/إزالة العناصر، تحديث الكميات، الاحتفاظ بحالة العربة
  • تدفق الدفع: دفع متعدد الخطوات مع العنوان والشحن والدفع
  • معالجة الدفع: تكامل دفع آمن مع Stripe أو ما شابه
  • إدارة الطلبات: تاريخ الطلب والتتبع وتحديثات الحالة
  • حسابات المستخدمين: المصادقة والملفات الشخصية والعناوين المحفوظة

مخطط قاعدة البيانات للتجارة الإلكترونية

مثال على مخطط Prisma:
model Product {
  id          String   @id @default(cuid())
  name        String
  slug        String   @unique
  description String
  price       Float
  compareAtPrice Float?
  images      String[]
  category    Category @relation(fields: [categoryId], references: [id])
  categoryId  String
  variants    ProductVariant[]
  inventory   Int      @default(0)
  published   Boolean  @default(false)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

model ProductVariant {
  id        String  @id @default(cuid())
  product   Product @relation(fields: [productId], references: [id])
  productId String
  name      String
  sku       String  @unique
  price     Float
  inventory Int     @default(0)
  options   Json    // { size: "M", color: "Blue" }
}

model Category {
  id          String    @id @default(cuid())
  name        String
  slug        String    @unique
  description String?
  products    Product[]
  parent      Category? @relation("CategoryToCategory", fields: [parentId], references: [id])
  parentId    String?
  children    Category[] @relation("CategoryToCategory")
}

model Cart {
  id        String     @id @default(cuid())
  userId    String?
  items     CartItem[]
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
}

model CartItem {
  id        String  @id @default(cuid())
  cart      Cart    @relation(fields: [cartId], references: [id])
  cartId    String
  productId String
  variantId String?
  quantity  Int
  price     Float
}

model Order {
  id              String      @id @default(cuid())
  orderNumber     String      @unique
  user            User        @relation(fields: [userId], references: [id])
  userId          String
  items           OrderItem[]
  subtotal        Float
  tax             Float
  shipping        Float
  total           Float
  status          OrderStatus @default(PENDING)
  shippingAddress Json
  billingAddress  Json
  paymentIntentId String?
  createdAt       DateTime    @default(now())
  updatedAt       DateTime    @updatedAt
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

model OrderItem {
  id        String @id @default(cuid())
  order     Order  @relation(fields: [orderId], references: [id])
  orderId   String
  productId String
  variantId String?
  quantity  Int
  price     Float
  name      String
}

تنفيذ كتالوج المنتجات

app/products/page.tsx:
import { prisma } from '@/lib/prisma';
import ProductGrid from '@/components/ProductGrid';
import ProductFilters from '@/components/ProductFilters';
import { Suspense } from 'react';

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: {
    category?: string;
    sort?: string;
    minPrice?: string;
    maxPrice?: string;
    page?: string;
  };
}) {
  const page = parseInt(searchParams.page || '1');
  const limit = 12;
  const skip = (page - 1) * limit;

  const where = {
    published: true,
    ...(searchParams.category && {
      category: { slug: searchParams.category },
    }),
    ...(searchParams.minPrice && {
      price: { gte: parseFloat(searchParams.minPrice) },
    }),
    ...(searchParams.maxPrice && {
      price: { lte: parseFloat(searchParams.maxPrice) },
    }),
  };

  const orderBy = getOrderBy(searchParams.sort);

  const [products, total] = await Promise.all([
    prisma.product.findMany({
      where,
      orderBy,
      skip,
      take: limit,
      include: {
        category: true,
      },
    }),
    prisma.product.count({ where }),
  ]);

  const totalPages = Math.ceil(total / limit);

  return (
    <div className="products-page">
      <h1>المنتجات</h1>

      <div className="products-layout">
        <aside className="filters">
          <Suspense fallback={<div>جاري تحميل الفلاتر...</div>}>
            <ProductFilters />
          </Suspense>
        </aside>

        <main>
          <ProductGrid products={products} />

          {totalPages > 1 && (
            <Pagination
              currentPage={page}
              totalPages={totalPages}
            />
          )}
        </main>
      </div>
    </div>
  );
}

function getOrderBy(sort?: string) {
  switch (sort) {
    case 'price-asc':
      return { price: 'asc' as const };
    case 'price-desc':
      return { price: 'desc' as const };
    case 'name':
      return { name: 'asc' as const };
    default:
      return { createdAt: 'desc' as const };
  }
}

صفحة تفاصيل المنتج

app/products/[slug]/page.tsx:
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import ProductImages from '@/components/ProductImages';
import AddToCartButton from '@/components/AddToCartButton';
import ProductVariants from '@/components/ProductVariants';

export async function generateStaticParams() {
  const products = await prisma.product.findMany({
    where: { published: true },
    select: { slug: true },
  });

  return products.map((product) => ({
    slug: product.slug,
  }));
}

export async function generateMetadata({ params }: Props) {
  const product = await prisma.product.findUnique({
    where: { slug: params.slug },
  });

  if (!product) return {};

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      images: product.images,
    },
  };
}

export default async function ProductPage({
  params,
}: {
  params: { slug: string };
}) {
  const product = await prisma.product.findUnique({
    where: { slug: params.slug },
    include: {
      category: true,
      variants: true,
    },
  });

  if (!product) {
    notFound();
  }

  const jsonLd = {
    '@@context': 'https://schema.org',
    '@@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.images,
    offers: {
      '@@type': 'Offer',
      price: product.price,
      priceCurrency: 'SAR',
      availability: product.inventory > 0
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock',
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />

      <div className="product-page">
        <div className="product-grid">
          <div className="product-images">
            <ProductImages images={product.images} />
          </div>

          <div className="product-info">
            <h1>{product.name}</h1>

            <div className="product-price">
              {product.compareAtPrice && (
                <span className="compare-price">
                  {product.compareAtPrice} ريال
                </span>
              )}
              <span className="price">{product.price} ريال</span>
            </div>

            <p className="description">{product.description}</p>

            {product.variants.length > 0 && (
              <ProductVariants variants={product.variants} />
            )}

            <AddToCartButton
              productId={product.id}
              name={product.name}
              price={product.price}
              image={product.images[0]}
              inStock={product.inventory > 0}
            />

            <div className="product-meta">
              <p>الفئة: {product.category.name}</p>
              <p>
                التوفر:{" "}
                {product.inventory > 0 ? 'متوفر' : 'غير متوفر'}
              </p>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

تنفيذ عربة التسوق

lib/cart.ts (مخزن Zustand):
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartItem {
  id: string;
  productId: string;
  variantId?: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  getTotal: () => number;
  getItemCount: () => number;
}

export const useCart = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],

      addItem: (newItem) =>
        set((state) => {
          const existingItem = state.items.find(
            (item) => item.id === newItem.id
          );

          if (existingItem) {
            return {
              items: state.items.map((item) =>
                item.id === newItem.id
                  ? { ...item, quantity: item.quantity + 1 }
                  : item
              ),
            };
          }

          return {
            items: [...state.items, { ...newItem, quantity: 1 }],
          };
        }),

      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((item) => item.id !== id),
        })),

      updateQuantity: (id, quantity) =>
        set((state) => ({
          items: state.items.map((item) =>
            item.id === id ? { ...item, quantity } : item
          ),
        })),

      clearCart: () => set({ items: [] }),

      getTotal: () => {
        return get().items.reduce(
          (total, item) => total + item.price * item.quantity,
          0
        );
      },

      getItemCount: () => {
        return get().items.reduce((count, item) => count + item.quantity, 0);
      },
    }),
    {
      name: 'shopping-cart',
    }
  )
);
components/AddToCartButton.tsx:
'use client';

import { useCart } from '@/lib/cart';
import { useState } from 'react';

interface Props {
  productId: string;
  name: string;
  price: number;
  image: string;
  inStock: boolean;
}

export default function AddToCartButton({
  productId,
  name,
  price,
  image,
  inStock,
}: Props) {
  const { addItem } = useCart();
  const [added, setAdded] = useState(false);

  const handleAddToCart = () => {
    addItem({
      id: productId,
      productId,
      name,
      price,
      image,
    });

    setAdded(true);
    setTimeout(() => setAdded(false), 2000);
  };

  return (
    <button
      onClick={handleAddToCart}
      disabled={!inStock}
      className="add-to-cart-button"
    >
      {added ? 'تمت الإضافة إلى السلة!' : inStock ? 'أضف إلى السلة' : 'غير متوفر'}
    </button>
  );
}
app/cart/page.tsx:
'use client';

import { useCart } from '@/lib/cart';
import Link from 'next/link';
import Image from 'next/image';

export default function CartPage() {
  const { items, removeItem, updateQuantity, getTotal } = useCart();

  if (items.length === 0) {
    return (
      <div className="empty-cart">
        <h1>سلة التسوق فارغة</h1>
        <Link href="/products">تابع التسوق</Link>
      </div>
    );
  }

  return (
    <div className="cart-page">
      <h1>سلة التسوق</h1>

      <div className="cart-items">
        {items.map((item) => (
          <div key={item.id} className="cart-item">
            <Image
              src={item.image}
              alt={item.name}
              width={100}
              height={100}
            />

            <div className="item-details">
              <h3>{item.name}</h3>
              <p>{item.price} ريال</p>
            </div>

            <div className="item-quantity">
              <button
                onClick={() =>
                  updateQuantity(item.id, Math.max(1, item.quantity - 1))
                }
              >
                -
              </button>
              <span>{item.quantity}</span>
              <button
                onClick={() => updateQuantity(item.id, item.quantity + 1)}
              >
                +
              </button>
            </div>

            <div className="item-total">
              {(item.price * item.quantity).toFixed(2)} ريال
            </div>

            <button
              onClick={() => removeItem(item.id)}
              className="remove-button"
            >
              إزالة
            </button>
          </div>
        ))}
      </div>

      <div className="cart-summary">
        <h2>ملخص الطلب</h2>
        <div className="summary-row">
          <span>المجموع الفرعي:</span>
          <span>{getTotal().toFixed(2)} ريال</span>
        </div>
        <div className="summary-row">
          <span>الشحن:</span>
          <span>يحسب عند الدفع</span>
        </div>
        <div className="summary-total">
          <span>المجموع:</span>
          <span>{getTotal().toFixed(2)} ريال</span>
        </div>

        <Link href="/checkout" className="checkout-button">
          المتابعة إلى الدفع
        </Link>
      </div>
    </div>
  );
}

تكامل الدفع عبر Stripe

تثبيت Stripe:
npm install stripe @stripe/stripe-js
lib/stripe.ts:
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-01-01',
});
app/api/checkout/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getServerSession } from 'next-auth';

export async function POST(request: NextRequest) {
  try {
    const session = await getServerSession();

    if (!session?.user) {
      return NextResponse.json(
        { error: 'غير مصرح' },
        { status: 401 }
      );
    }

    const { items } = await request.json();

    // إنشاء جلسة دفع Stripe
    const checkoutSession = await stripe.checkout.sessions.create({
      mode: 'payment',
      customer_email: session.user.email,
      line_items: items.map((item: any) => ({
        price_data: {
          currency: 'sar',
          product_data: {
            name: item.name,
            images: [item.image],
          },
          unit_amount: Math.round(item.price * 100),
        },
        quantity: item.quantity,
      })),
      success_url: \`${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}\`,
      cancel_url: \`${process.env.NEXT_PUBLIC_URL}/cart\`,
      metadata: {
        userId: session.user.id,
      },
    });

    return NextResponse.json({ url: checkoutSession.url });
  } catch (error) {
    console.error('خطأ في الدفع:', error);
    return NextResponse.json(
      { error: 'فشل في إنشاء جلسة الدفع' },
      { status: 500 }
    );
  }
}
app/checkout/page.tsx:
'use client';

import { useCart } from '@/lib/cart';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function CheckoutPage() {
  const { items, getTotal } = useCart();
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const handleCheckout = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      });

      const { url } = await response.json();

      if (url) {
        router.push(url);
      }
    } catch (error) {
      console.error('خطأ في الدفع:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="checkout-page">
      <h1>الدفع</h1>

      <div className="checkout-summary">
        <h2>ملخص الطلب</h2>
        {items.map((item) => (
          <div key={item.id} className="checkout-item">
            <span>{item.name} × {item.quantity}</span>
            <span>{(item.price * item.quantity).toFixed(2)} ريال</span>
          </div>
        ))}

        <div className="checkout-total">
          <span>المجموع:</span>
          <span>{getTotal().toFixed(2)} ريال</span>
        </div>

        <button
          onClick={handleCheckout}
          disabled={loading}
          className="checkout-button"
        >
          {loading ? 'جاري المعالجة...' : 'الدفع باستخدام Stripe'}
        </button>
      </div>
    </div>
  );
}

Webhook لمعالجة الطلب

app/api/webhooks/stripe/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';
import Stripe from 'stripe';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (error) {
    return NextResponse.json(
      { error: 'توقيع غير صالح' },
      { status: 400 }
    );
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;

    // إنشاء طلب في قاعدة البيانات
    await prisma.order.create({
      data: {
        orderNumber: \`ORD-${Date.now()}\`,
        userId: session.metadata?.userId!,
        total: session.amount_total! / 100,
        paymentIntentId: session.payment_intent as string,
        status: 'PROCESSING',
        // ... بيانات الطلب الأخرى
      },
    });
  }

  return NextResponse.json({ received: true });
}
تمرين:
  1. قم بإعداد مخطط قاعدة بيانات المنتج باستخدام Prisma
  2. أنشئ كتالوج منتجات مع التصفية والترتيب
  3. قم بتنفيذ عربة تسوق باستخدام Zustand
  4. قم ببناء صفحة تفاصيل منتج مع المتغيرات
  5. قم بدمج Stripe لمعالجة الدفع
  6. قم بإعداد webhooks لتنفيذ الطلب
  7. أضف ميزات تاريخ الطلب والتتبع
أفضل ممارسات التجارة الإلكترونية:
  • استخدم فهارس قاعدة البيانات المناسبة لاستعلامات المنتج
  • قم بتنفيذ إدارة المخزون وفحوصات المخزون
  • أضف بحث المنتج مع التطابق الغامض
  • قم بتحسين الصور لأحجام الشاشات المختلفة
  • قم بتنفيذ معالجة الأخطاء المناسبة للدفعات
  • أضف حالات التحميل لتجربة مستخدم أفضل
  • استخدم webhooks لمعالجة الطلبات الموثوقة
  • قم بتنفيذ الأمان المناسب لتدفقات الدفع