Medusa.js 2.0 — بناء متجر إلكتروني Headless مع Next.js (2026)

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

بديل مفتوح المصدر لـ Shopify، مصمم للمطورين. Medusa.js 2.0 هي منصة تجارة إلكترونية معيارية بالكامل بدون واجهة أمامية مقيّدة، تتيح لك بناء واجهات متاجر مخصصة مع أي إطار عمل. في هذا الدليل، نربطها مع Next.js لبناء متجر جاهز للإنتاج.

ما ستتعلمه

بنهاية هذا الدليل، ستكون قادراً على:

  • فهم بنية Medusa.js 2.0 وتصميمها المعياري
  • إعداد خادم Medusa مع PostgreSQL
  • بناء واجهة متجر Next.js متصلة بـ Medusa API
  • عرض المنتجات مع التصنيفات والمتغيرات
  • تنفيذ سلة تسوق بوظائف الإضافة والحذف
  • بناء تدفق دفع مع الشحن والدفع
  • نشر الخادم والواجهة الأمامية في بيئة الإنتاج

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت (node --version)
  • PostgreSQL 14+ يعمل محلياً أو عبر Docker
  • معرفة بـ React و Next.js (App Router، Server Components)
  • أساسيات TypeScript
  • محرر أكواد — يُفضّل VS Code
  • فهم أساسي لـ REST APIs

لماذا Medusa.js 2.0؟

إذا جربت تخصيص Shopify خارج قوالب Liquid أو عانيت مع إضافات WooCommerce، فأنت تعرف المعاناة. Medusa.js 2.0 تتبع نهجاً مختلفاً:

الميزةMedusa 2.0ShopifyWooCommerce
مفتوح المصدرنعملانعم
Headless-firstنعمجزئيبإضافة
وحدات مخصصةدرجة أولىمحدودإضافات
متعدد المناطقمدمجمكلفيدوي
TypeScriptكاملLiquidPHP
استضافة ذاتيةنعملانعم

أُعيد كتابة Medusa 2.0 بالكامل من الصفر بـ بنية معيارية. كل ميزة — المنتجات، السلة، الطلبات، المدفوعات — هي وحدة مستقلة يمكنك توسيعها أو استبدالها أو إزالتها.

نظرة على البنية

┌─────────────────────┐     ┌──────────────────────┐
│   واجهة Next.js     │────▶│   خادم Medusa.js      │
│   (App Router)      │ API │   (Node.js + Express) │
│   المنفذ 3000       │◀────│   المنفذ 9000          │
└─────────────────────┘     └──────────┬───────────┘
                                       │
                            ┌──────────▼───────────┐
                            │     PostgreSQL        │
                            │     + Redis           │
                            └──────────────────────┘

الخطوة 1: إعداد خادم Medusa

تثبيت Medusa CLI

npm install -g @medusajs/medusa-cli

إنشاء مشروع Medusa جديد

medusa new my-store --v2
cd my-store

هذا ينشئ مشروع Medusa 2.0 يحتوي على:

  • src/ — الوحدات المخصصة ومسارات API
  • medusa-config.ts — الإعدادات المركزية
  • package.json — مع جميع الوحدات الأساسية مثبتة مسبقاً

إعداد قاعدة البيانات

عدّل medusa-config.ts:

import { defineConfig } from "@medusajs/framework/utils"
 
export default defineConfig({
  projectConfig: {
    databaseUrl: process.env.DATABASE_URL ||
      "postgres://localhost/medusa-store",
    redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
    http: {
      storeCors: process.env.STORE_CORS || "http://localhost:3000",
      adminCors: process.env.ADMIN_CORS || "http://localhost:9000",
      jwtSecret: process.env.JWT_SECRET || "supersecret",
      cookieSecret: process.env.COOKIE_SECRET || "supersecret",
    },
  },
})

إنشاء قاعدة البيانات وتعبئة البيانات التجريبية

# إنشاء قاعدة بيانات PostgreSQL
createdb medusa-store
 
# تشغيل الترحيلات
npx medusa db:migrate
 
# تعبئة ببيانات منتجات تجريبية
npx medusa seed --seed-file=src/scripts/seed.ts

تشغيل الخادم

npx medusa develop

خادم Medusa الآن يعمل على http://localhost:9000. اختبره:

curl http://localhost:9000/store/products | jq '.products[0].title'

الخطوة 2: إنشاء واجهة المتجر بـ Next.js

تهيئة المشروع

npx create-next-app@latest my-storefront --typescript --tailwind --app --src-dir
cd my-storefront

تثبيت التبعيات

npm install @medusajs/js-sdk @medusajs/types

إعداد عميل Medusa

أنشئ src/lib/medusa.ts:

import Medusa from "@medusajs/js-sdk"
 
export const medusa = new Medusa({
  baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL || "http://localhost:9000",
  publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || "",
})

أضف إلى .env.local:

NEXT_PUBLIC_MEDUSA_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_your_key_here

يمكنك إيجاد مفتاح API القابل للنشر في لوحة تحكم Medusa على http://localhost:9000/app.


الخطوة 3: بناء كتالوج المنتجات

صفحة قائمة المنتجات

أنشئ src/app/products/page.tsx:

import { medusa } from "@/lib/medusa"
import Link from "next/link"
import Image from "next/image"
 
export default async function ProductsPage() {
  const { products } = await medusa.store.product.list({
    limit: 20,
    fields: "+variants.calculated_price",
  })
 
  return (
    <main className="max-w-7xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">جميع المنتجات</h1>
 
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
        {products.map((product) => (
          <Link
            key={product.id}
            href={`/products/${product.handle}`}
            className="group"
          >
            <div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
              {product.thumbnail && (
                <Image
                  src={product.thumbnail}
                  alt={product.title}
                  width={500}
                  height={500}
                  className="object-cover w-full h-full group-hover:scale-105 transition-transform"
                />
              )}
            </div>
            <h2 className="mt-3 text-lg font-medium">{product.title}</h2>
            <p className="text-gray-500">
              {formatPrice(product.variants?.[0]?.calculated_price)}
            </p>
          </Link>
        ))}
      </div>
    </main>
  )
}
 
function formatPrice(price: any) {
  if (!price?.calculated_amount) return ""
  return new Intl.NumberFormat("ar-SA", {
    style: "currency",
    currency: price.currency_code || "usd",
  }).format(price.calculated_amount / 100)
}

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

أنشئ src/app/products/[handle]/page.tsx:

import { medusa } from "@/lib/medusa"
import { notFound } from "next/navigation"
import Image from "next/image"
import { AddToCartButton } from "@/components/add-to-cart"
 
interface Props {
  params: Promise<{ handle: string }>
}
 
export default async function ProductPage({ params }: Props) {
  const { handle } = await params
 
  const { products } = await medusa.store.product.list({
    handle,
    fields: "+variants.calculated_price,+variants.inventory_quantity",
  })
 
  const product = products[0]
  if (!product) notFound()
 
  return (
    <main className="max-w-7xl mx-auto px-4 py-12">
      <div className="grid grid-cols-1 md:grid-cols-2 gap-12">
        {/* صور المنتج */}
        <div className="space-y-4">
          {product.images?.map((image, i) => (
            <div key={i} className="aspect-square rounded-xl overflow-hidden bg-gray-100">
              <Image
                src={image.url}
                alt={product.title}
                width={800}
                height={800}
                className="object-cover w-full h-full"
              />
            </div>
          ))}
        </div>
 
        {/* معلومات المنتج */}
        <div>
          <h1 className="text-3xl font-bold">{product.title}</h1>
          <p className="mt-2 text-2xl text-gray-700">
            {formatPrice(product.variants?.[0]?.calculated_price)}
          </p>
 
          <div
            className="mt-6 prose"
            dangerouslySetInnerHTML={{ __html: product.description || "" }}
          />
 
          {/* اختيار المتغير */}
          {product.options?.map((option) => (
            <div key={option.id} className="mt-6">
              <h3 className="font-medium mb-2">{option.title}</h3>
              <div className="flex gap-2">
                {option.values?.map((value) => (
                  <button
                    key={value.id}
                    className="px-4 py-2 border rounded-lg hover:border-black transition"
                  >
                    {value.value}
                  </button>
                ))}
              </div>
            </div>
          ))}
 
          <AddToCartButton
            productId={product.id}
            variantId={product.variants?.[0]?.id || ""}
          />
        </div>
      </div>
    </main>
  )
}
 
function formatPrice(price: any) {
  if (!price?.calculated_amount) return ""
  return new Intl.NumberFormat("ar-SA", {
    style: "currency",
    currency: price.currency_code || "usd",
  }).format(price.calculated_amount / 100)
}

الخطوة 4: تنفيذ سلة التسوق

مزوّد سياق السلة

أنشئ src/lib/cart-context.tsx:

"use client"
 
import { createContext, useContext, useState, useEffect, useCallback } from "react"
import { medusa } from "@/lib/medusa"
 
interface CartContextType {
  cart: any
  addItem: (variantId: string, quantity?: number) => Promise<void>
  removeItem: (lineItemId: string) => Promise<void>
  updateQuantity: (lineItemId: string, quantity: number) => Promise<void>
  itemCount: number
}
 
const CartContext = createContext<CartContextType | null>(null)
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [cart, setCart] = useState<any>(null)
 
  const initCart = useCallback(async () => {
    let cartId = localStorage.getItem("cart_id")
 
    if (cartId) {
      try {
        const { cart } = await medusa.store.cart.retrieve(cartId)
        setCart(cart)
        return
      } catch {
        localStorage.removeItem("cart_id")
      }
    }
 
    const { cart: newCart } = await medusa.store.cart.create({})
    localStorage.setItem("cart_id", newCart.id)
    setCart(newCart)
  }, [])
 
  useEffect(() => {
    initCart()
  }, [initCart])
 
  const addItem = async (variantId: string, quantity = 1) => {
    if (!cart) return
    const { cart: updated } = await medusa.store.cart.addLineItem(cart.id, {
      variant_id: variantId,
      quantity,
    })
    setCart(updated)
  }
 
  const removeItem = async (lineItemId: string) => {
    if (!cart) return
    const { cart: updated } = await medusa.store.cart.removeLineItem(
      cart.id,
      lineItemId
    )
    setCart(updated)
  }
 
  const updateQuantity = async (lineItemId: string, quantity: number) => {
    if (!cart) return
    const { cart: updated } = await medusa.store.cart.updateLineItem(
      cart.id,
      lineItemId,
      { quantity }
    )
    setCart(updated)
  }
 
  const itemCount = cart?.items?.reduce(
    (sum: number, item: any) => sum + item.quantity, 0
  ) || 0
 
  return (
    <CartContext.Provider value={{ cart, addItem, removeItem, updateQuantity, itemCount }}>
      {children}
    </CartContext.Provider>
  )
}
 
export const useCart = () => {
  const ctx = useContext(CartContext)
  if (!ctx) throw new Error("useCart must be used within CartProvider")
  return ctx
}

زر الإضافة للسلة

أنشئ src/components/add-to-cart.tsx:

"use client"
 
import { useCart } from "@/lib/cart-context"
import { useState } from "react"
 
export function AddToCartButton({
  variantId,
}: {
  productId: string
  variantId: string
}) {
  const { addItem } = useCart()
  const [loading, setLoading] = useState(false)
 
  const handleAdd = async () => {
    setLoading(true)
    await addItem(variantId)
    setLoading(false)
  }
 
  return (
    <button
      onClick={handleAdd}
      disabled={loading}
      className="mt-8 w-full bg-black text-white py-4 rounded-xl font-medium
                 hover:bg-gray-800 disabled:opacity-50 transition"
    >
      {loading ? "جاري الإضافة..." : "أضف إلى السلة"}
    </button>
  )
}

مكوّن درج السلة

أنشئ src/components/cart-drawer.tsx:

"use client"
 
import { useCart } from "@/lib/cart-context"
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
 
export function CartDrawer() {
  const { cart, removeItem, updateQuantity, itemCount } = useCart()
  const [open, setOpen] = useState(false)
 
  return (
    <>
      <button onClick={() => setOpen(true)} className="relative">
        السلة
        {itemCount > 0 && (
          <span className="absolute -top-2 -right-3 bg-black text-white text-xs
                           w-5 h-5 rounded-full flex items-center justify-center">
            {itemCount}
          </span>
        )}
      </button>
 
      {open && (
        <div className="fixed inset-0 z-50 flex justify-end">
          <div className="bg-black/50 flex-1" onClick={() => setOpen(false)} />
          <div className="w-full max-w-md bg-white h-full overflow-y-auto p-6">
            <div className="flex justify-between items-center mb-6">
              <h2 className="text-xl font-bold">سلة التسوق</h2>
              <button onClick={() => setOpen(false)}>إغلاق</button>
            </div>
 
            {!cart?.items?.length ? (
              <p className="text-gray-500">سلة التسوق فارغة.</p>
            ) : (
              <>
                {cart.items.map((item: any) => (
                  <div key={item.id} className="flex gap-4 py-4 border-b">
                    {item.thumbnail && (
                      <Image
                        src={item.thumbnail}
                        alt={item.title}
                        width={80}
                        height={80}
                        className="rounded-lg object-cover"
                      />
                    )}
                    <div className="flex-1">
                      <h3 className="font-medium">{item.title}</h3>
                      <p className="text-sm text-gray-500">{item.variant?.title}</p>
                      <div className="flex items-center gap-2 mt-2">
                        <button
                          onClick={() => updateQuantity(item.id, item.quantity - 1)}
                          disabled={item.quantity <= 1}
                          className="w-8 h-8 border rounded"
                        >
                          -
                        </button>
                        <span>{item.quantity}</span>
                        <button
                          onClick={() => updateQuantity(item.id, item.quantity + 1)}
                          className="w-8 h-8 border rounded"
                        >
                          +
                        </button>
                      </div>
                    </div>
                    <button
                      onClick={() => removeItem(item.id)}
                      className="text-red-500 text-sm"
                    >
                      حذف
                    </button>
                  </div>
                ))}
 
                <div className="mt-6 pt-4 border-t">
                  <div className="flex justify-between text-lg font-bold">
                    <span>المجموع</span>
                    <span>{formatPrice(cart.total, cart.currency_code)}</span>
                  </div>
                  <Link
                    href="/checkout"
                    onClick={() => setOpen(false)}
                    className="block mt-4 w-full bg-black text-white text-center
                               py-4 rounded-xl font-medium hover:bg-gray-800"
                  >
                    إتمام الشراء
                  </Link>
                </div>
              </>
            )}
          </div>
        </div>
      )}
    </>
  )
}
 
function formatPrice(amount: number, currency: string) {
  return new Intl.NumberFormat("ar-SA", {
    style: "currency",
    currency: currency || "usd",
  }).format(amount / 100)
}

الخطوة 5: بناء تدفق الدفع

صفحة الدفع

أنشئ src/app/checkout/page.tsx:

"use client"
 
import { useCart } from "@/lib/cart-context"
import { medusa } from "@/lib/medusa"
import { useState } from "react"
import { useRouter } from "next/navigation"
 
export default function CheckoutPage() {
  const { cart } = useCart()
  const router = useRouter()
  const [loading, setLoading] = useState(false)
  const [step, setStep] = useState<"shipping" | "payment">("shipping")
 
  const [shippingForm, setShippingForm] = useState({
    first_name: "",
    last_name: "",
    address_1: "",
    city: "",
    country_code: "sa",
    postal_code: "",
    phone: "",
    email: "",
  })
 
  const handleShippingSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!cart) return
 
    setLoading(true)
 
    // إضافة البريد الإلكتروني للسلة
    await medusa.store.cart.update(cart.id, {
      email: shippingForm.email,
    })
 
    // إضافة عنوان الشحن
    await medusa.store.cart.update(cart.id, {
      shipping_address: {
        first_name: shippingForm.first_name,
        last_name: shippingForm.last_name,
        address_1: shippingForm.address_1,
        city: shippingForm.city,
        country_code: shippingForm.country_code,
        postal_code: shippingForm.postal_code,
        phone: shippingForm.phone,
      },
    })
 
    // الحصول على خيارات الشحن
    const { shipping_options } = await medusa.store.fulfillment
      .listCartOptions({ cart_id: cart.id })
 
    if (shipping_options?.length) {
      await medusa.store.cart.addShippingMethod(cart.id, {
        option_id: shipping_options[0].id,
      })
    }
 
    setLoading(false)
    setStep("payment")
  }
 
  const handlePaymentSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!cart) return
 
    setLoading(true)
 
    // تهيئة جلسة الدفع
    await medusa.store.cart.initiatePaymentSession(cart.id, {
      provider_id: "pp_system_default",
    })
 
    // إتمام السلة (إنشاء الطلب)
    const { order } = await medusa.store.cart.complete(cart.id) as any
 
    localStorage.removeItem("cart_id")
    router.push(`/order/${order.id}`)
  }
 
  if (!cart) return <div className="p-12 text-center">جاري تحميل السلة...</div>
 
  return (
    <main className="max-w-2xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">إتمام الشراء</h1>
 
      {/* مؤشر التقدم */}
      <div className="flex gap-4 mb-8">
        <span className={step === "shipping" ? "font-bold" : "text-gray-400"}>
          1. الشحن
        </span>
        <span className={step === "payment" ? "font-bold" : "text-gray-400"}>
          2. الدفع
        </span>
      </div>
 
      {step === "shipping" && (
        <form onSubmit={handleShippingSubmit} className="space-y-4">
          <div className="grid grid-cols-2 gap-4">
            <input
              placeholder="الاسم الأول"
              required
              value={shippingForm.first_name}
              onChange={(e) => setShippingForm(f => ({ ...f, first_name: e.target.value }))}
              className="border rounded-lg px-4 py-3"
            />
            <input
              placeholder="اسم العائلة"
              required
              value={shippingForm.last_name}
              onChange={(e) => setShippingForm(f => ({ ...f, last_name: e.target.value }))}
              className="border rounded-lg px-4 py-3"
            />
          </div>
          <input
            placeholder="البريد الإلكتروني"
            type="email"
            required
            value={shippingForm.email}
            onChange={(e) => setShippingForm(f => ({ ...f, email: e.target.value }))}
            className="w-full border rounded-lg px-4 py-3"
          />
          <input
            placeholder="العنوان"
            required
            value={shippingForm.address_1}
            onChange={(e) => setShippingForm(f => ({ ...f, address_1: e.target.value }))}
            className="w-full border rounded-lg px-4 py-3"
          />
          <div className="grid grid-cols-2 gap-4">
            <input
              placeholder="المدينة"
              required
              value={shippingForm.city}
              onChange={(e) => setShippingForm(f => ({ ...f, city: e.target.value }))}
              className="border rounded-lg px-4 py-3"
            />
            <input
              placeholder="الرمز البريدي"
              required
              value={shippingForm.postal_code}
              onChange={(e) => setShippingForm(f => ({ ...f, postal_code: e.target.value }))}
              className="border rounded-lg px-4 py-3"
            />
          </div>
          <input
            placeholder="رقم الهاتف"
            value={shippingForm.phone}
            onChange={(e) => setShippingForm(f => ({ ...f, phone: e.target.value }))}
            className="w-full border rounded-lg px-4 py-3"
          />
 
          <button
            type="submit"
            disabled={loading}
            className="w-full bg-black text-white py-4 rounded-xl font-medium
                       hover:bg-gray-800 disabled:opacity-50"
          >
            {loading ? "جاري المعالجة..." : "المتابعة إلى الدفع"}
          </button>
        </form>
      )}
 
      {step === "payment" && (
        <form onSubmit={handlePaymentSubmit} className="space-y-4">
          <div className="bg-gray-50 rounded-xl p-6">
            <h3 className="font-medium mb-2">ملخص الطلب</h3>
            {cart.items?.map((item: any) => (
              <div key={item.id} className="flex justify-between py-2">
                <span>{item.title} x {item.quantity}</span>
                <span>{formatPrice(item.subtotal, cart.currency_code)}</span>
              </div>
            ))}
            <div className="border-t mt-4 pt-4 flex justify-between font-bold">
              <span>المجموع</span>
              <span>{formatPrice(cart.total, cart.currency_code)}</span>
            </div>
          </div>
 
          <button
            type="submit"
            disabled={loading}
            className="w-full bg-black text-white py-4 rounded-xl font-medium
                       hover:bg-gray-800 disabled:opacity-50"
          >
            {loading ? "جاري تأكيد الطلب..." : "تأكيد الطلب"}
          </button>
        </form>
      )}
    </main>
  )
}
 
function formatPrice(amount: number, currency: string) {
  return new Intl.NumberFormat("ar-SA", {
    style: "currency",
    currency: currency || "usd",
  }).format(amount / 100)
}

الخطوة 6: التصنيفات وتصفية المنتجات

جلب التصنيفات

أنشئ src/app/categories/[handle]/page.tsx:

import { medusa } from "@/lib/medusa"
import Link from "next/link"
import Image from "next/image"
 
interface Props {
  params: Promise<{ handle: string }>
}
 
export default async function CategoryPage({ params }: Props) {
  const { handle } = await params
 
  const { product_categories } = await medusa.store.category.list({
    handle,
  })
 
  const category = product_categories[0]
  if (!category) return <div>التصنيف غير موجود</div>
 
  const { products } = await medusa.store.product.list({
    category_id: [category.id],
    fields: "+variants.calculated_price",
  })
 
  return (
    <main className="max-w-7xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-2">{category.name}</h1>
      <p className="text-gray-500 mb-8">{category.description}</p>
 
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
        {products.map((product) => (
          <Link key={product.id} href={`/products/${product.handle}`} className="group">
            <div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
              {product.thumbnail && (
                <Image
                  src={product.thumbnail}
                  alt={product.title}
                  width={500}
                  height={500}
                  className="object-cover w-full h-full group-hover:scale-105 transition-transform"
                />
              )}
            </div>
            <h2 className="mt-3 text-lg font-medium">{product.title}</h2>
          </Link>
        ))}
      </div>
    </main>
  )
}

شريط التنقل بين التصنيفات

أنشئ src/components/category-nav.tsx:

import { medusa } from "@/lib/medusa"
import Link from "next/link"
 
export async function CategoryNav() {
  const { product_categories } = await medusa.store.category.list({
    parent_category_id: "null",
  })
 
  return (
    <nav className="flex gap-6 overflow-x-auto py-4 px-4 border-b">
      <Link href="/products" className="whitespace-nowrap hover:text-black text-gray-600">
        جميع المنتجات
      </Link>
      {product_categories.map((cat) => (
        <Link
          key={cat.id}
          href={`/categories/${cat.handle}`}
          className="whitespace-nowrap hover:text-black text-gray-600"
        >
          {cat.name}
        </Link>
      ))}
    </nav>
  )
}

الخطوة 7: وظيفة البحث

أنشئ src/app/search/page.tsx:

"use client"
 
import { medusa } from "@/lib/medusa"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { useSearchParams } from "next/navigation"
 
export default function SearchPage() {
  const searchParams = useSearchParams()
  const query = searchParams.get("q") || ""
  const [products, setProducts] = useState<any[]>([])
  const [loading, setLoading] = useState(false)
 
  useEffect(() => {
    if (!query) return
 
    setLoading(true)
    medusa.store.product
      .list({ q: query, limit: 20 })
      .then(({ products }) => setProducts(products))
      .finally(() => setLoading(false))
  }, [query])
 
  return (
    <main className="max-w-7xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">
        نتائج البحث عن &quot;{query}&quot;
      </h1>
 
      {loading && <p>جاري البحث...</p>}
 
      {!loading && products.length === 0 && query && (
        <p className="text-gray-500">لم يتم العثور على منتجات.</p>
      )}
 
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
        {products.map((product) => (
          <Link key={product.id} href={`/products/${product.handle}`} className="group">
            <div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
              {product.thumbnail && (
                <Image
                  src={product.thumbnail}
                  alt={product.title}
                  width={500}
                  height={500}
                  className="object-cover w-full h-full group-hover:scale-105 transition-transform"
                />
              )}
            </div>
            <h2 className="mt-3 text-lg font-medium">{product.title}</h2>
          </Link>
        ))}
      </div>
    </main>
  )
}

الخطوة 8: دعم المناطق المتعددة

من أبرز ميزات Medusa 2.0 الدعم المدمج للمناطق المتعددة. هذا مثالي لمتاجر منطقة الشرق الأوسط وشمال أفريقيا التي تخدم عدة دول.

إعداد المناطق

في لوحة تحكم Medusa (http://localhost:9000/app)، أنشئ مناطق:

  1. الخليج العربي — العملة: SAR، الدول: SA, AE, KW, BH, OM, QA
  2. شمال أفريقيا — العملة: TND، الدول: TN, DZ, MA, LY, EG
  3. دولي — العملة: USD، الدول: US, GB, DE, FR

كشف المنطقة في Next.js

أنشئ src/lib/region.ts:

import { medusa } from "./medusa"
 
export async function getRegionByCountry(countryCode: string) {
  const { regions } = await medusa.store.region.list()
 
  return regions.find((region) =>
    region.countries?.some((c) => c.iso_2 === countryCode)
  )
}
 
export async function getDefaultRegion() {
  const { regions } = await medusa.store.region.list()
  return regions[0]
}

الخطوة 9: النشر في بيئة الإنتاج

نشر خادم Medusa

الخيار أ: Railway (موصى به)

# تثبيت Railway CLI
npm install -g @railway/cli
 
# تسجيل الدخول والنشر
railway login
railway init
railway add --plugin postgresql
railway add --plugin redis
 
# تعيين متغيرات البيئة
railway variables set JWT_SECRET="your-production-secret"
railway variables set COOKIE_SECRET="your-cookie-secret"
railway variables set STORE_CORS="https://your-storefront.vercel.app"
 
# النشر
railway up

الخيار ب: Docker

أنشئ Dockerfile:

FROM node:20-alpine
 
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
 
EXPOSE 9000
CMD ["npm", "start"]

نشر واجهة Next.js

# النشر على Vercel
npx vercel --prod
 
# تعيين متغيرات البيئة في لوحة تحكم Vercel
# NEXT_PUBLIC_MEDUSA_URL=https://your-medusa-backend.railway.app
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_live_xxx

حل المشاكل الشائعة

المشاكل المتكررة

أخطاء CORS عند ربط الواجهة الأمامية بالخادم

تأكد أن STORE_CORS في إعدادات Medusa يحتوي على رابط واجهتك الأمامية:

storeCors: "http://localhost:3000,https://your-store.vercel.app"

المنتجات لا تظهر الأسعار

تأكد من تمرير region_id أو أن لديك مفتاح API قابل للنشر مع قناة مبيعات افتراضية:

const { products } = await medusa.store.product.list({
  region_id: "reg_xxx",
  fields: "+variants.calculated_price",
})

فشل إنشاء السلة

تحقق أن Redis يعمل — Medusa 2.0 تستخدم Redis لجلسات السلة:

redis-cli ping  # يجب أن يُرجع PONG

الخطوات التالية

الآن بعد أن أصبح لديك متجر إلكتروني يعمل، يمكنك:

  • إضافة مدفوعات Stripe — ثبّت @medusajs/payment-stripe لمعالجة الدفع الحقيقي
  • إعداد Webhooks — لمعالجة أحداث الطلبات وإشعارات البريد الإلكتروني
  • بناء لوحة تحكم مخصصة — استخدم واجهة إدارة Medusa أو أنشئ صفحات مخصصة
  • إضافة تقييمات المنتجات — أنشئ وحدة مخصصة لمراجعات المستخدمين
  • تنفيذ قائمة المفضلات — استخدم نقاط النهاية المخصصة لتخزين المفضلات
  • تحسين SEO — أضف علامات meta وبيانات هيكلية وخرائط الموقع

الخلاصة

لقد بنيت متجراً إلكترونياً كاملاً بدون واجهة مقيّدة باستخدام Medusa.js 2.0 و Next.js. البنية المعيارية تعني أنه يمكنك تبديل أي جزء — استخدم Stripe بدلاً من مزود الدفع الافتراضي، أضف وحدة شحن مخصصة، أو اربط واجهة أمامية مختلفة تماماً.

Medusa 2.0 هي بديل مفتوح المصدر جاد لـ Shopify للمطورين الذين يريدون التحكم الكامل في منصة التجارة الخاصة بهم. الجمع مع Next.js يمنحك التصيير من جانب الخادم والتوليد الساكن وتجربة مطور عالمية المستوى.

الكود المصدري الكامل لهذا الدليل متاح كنقطة انطلاق — خصصه لسوقك، أضف علامتك التجارية، وأطلق متجرك.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على استخدام قواميس النطق مع ElevenLabs SDK.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

بناء تطبيق Full-Stack باستخدام Strapi 5 و Next.js 15 App Router

تعلّم كيفية بناء تطبيق متكامل باستخدام Strapi 5 كنظام إدارة محتوى headless و Next.js 15 App Router للواجهة الأمامية. يغطي هذا الدليل نمذجة المحتوى، واجهات REST API، والعرض من جانب الخادم، والنشر في بيئة الإنتاج.

30 د قراءة·

بناء واجهة GraphQL آمنة الأنواع مع Next.js App Router و Yoga و Pothos

تعلم كيفية بناء واجهة GraphQL API آمنة الأنواع بالكامل باستخدام Next.js 15 App Router و GraphQL Yoga و Pothos schema builder. يغطي هذا الدليل العملي تصميم المخططات والاستعلامات والتحولات والمصادقة وعميل React باستخدام urql.

30 د قراءة·

بناء موقع محتوى متكامل باستخدام Payload CMS 3 و Next.js

تعلّم كيفية بناء موقع محتوى كامل الميزات باستخدام Payload CMS 3 الذي يعمل مباشرة داخل Next.js App Router. يغطي هذا الدرس المجموعات، محرر النصوص الغنية، رفع الوسائط، المصادقة، والنشر في بيئة الإنتاج.

30 د قراءة·