إطار Next.js
التجارة الإلكترونية مع Next.js
التجارة الإلكترونية مع 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 });
}
تمرين:
- قم بإعداد مخطط قاعدة بيانات المنتج باستخدام Prisma
- أنشئ كتالوج منتجات مع التصفية والترتيب
- قم بتنفيذ عربة تسوق باستخدام Zustand
- قم ببناء صفحة تفاصيل منتج مع المتغيرات
- قم بدمج Stripe لمعالجة الدفع
- قم بإعداد webhooks لتنفيذ الطلب
- أضف ميزات تاريخ الطلب والتتبع
أفضل ممارسات التجارة الإلكترونية:
- استخدم فهارس قاعدة البيانات المناسبة لاستعلامات المنتج
- قم بتنفيذ إدارة المخزون وفحوصات المخزون
- أضف بحث المنتج مع التطابق الغامض
- قم بتحسين الصور لأحجام الشاشات المختلفة
- قم بتنفيذ معالجة الأخطاء المناسبة للدفعات
- أضف حالات التحميل لتجربة مستخدم أفضل
- استخدم webhooks لمعالجة الطلبات الموثوقة
- قم بتنفيذ الأمان المناسب لتدفقات الدفع