Skip to main content

Seller Marketplace

@doc-version: 1.0.0 @last-updated: 2026-01-10 The Seller Marketplace allows local businesses to create storefronts, manage inventory, and process orders. It’s integrated with Paystack for payments and linked to the Directory Engine for public visibility.

Overview

Core Principle: Businesses operate within their ward context. Sellers can choose a “selling ward” to reach customers in specific areas while maintaining their home ward identity.

Seller Journey

1. Claim/Create Directory Listing

2. Create Store (linked to listing)

3. Add Products

4. Configure Payments (Paystack)

5. Receive Orders → Fulfill → Get Paid

Architecture

Schema: business_intelligence

The marketplace uses the business_intelligence schema:
business_intelligence.stores     -- Store profiles and settings
business_intelligence.products   -- Product inventory
business_intelligence.orders     -- Order records (legacy: commerce.marketplace_orders)

The Stores Table

create table business_intelligence.stores (
  id uuid primary key default gen_random_uuid(),
  listing_id uuid references directory.listings(id),
  owner_id uuid references auth.users(id) not null,
  store_name text not null,
  
  -- Ward Context
  selling_ward_code text references public.wards(ward_code),
  
  -- Payment Integration
  paystack_subaccount_code text,
  currency text default 'ZAR',
  
  -- Status
  is_active boolean default true,
  
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- RLS: Owners can only manage their own stores
alter table business_intelligence.stores enable row level security;

create policy "Owners can manage their store"
  on business_intelligence.stores
  for all using (auth.uid() = owner_id);

The Products Table

create table business_intelligence.products (
  id uuid primary key default gen_random_uuid(),
  store_id uuid references business_intelligence.stores(id) on delete cascade,
  title text not null,
  description text,
  price decimal(12, 2) not null,
  stock_level int,
  images text[],
  is_active boolean default true,
  
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

Server Actions

Store Management

Get Store Settings

// app/actions/seller-settings.ts
export async function getStoreSettingsAction(): Promise<{
  success: boolean;
  data?: StoreSettingsData;
  error?: string;
}>
Returns the current user’s store profile including:
  • Store identity (name, description, logo)
  • Ward context (home ward, selling ward)
  • Operations (opening hours, contact info)
  • Payment settings (Paystack subaccount)

Update Store Profile

export async function updateStoreProfileAction(
  input: StoreProfileInput
): Promise<{ success: boolean; error?: string }>
Updates both business_intelligence.stores and linked directory.listings:
Updated in storesUpdated in listings
store_namebusiness_name
paystack_subaccount_codedescription
currencycategory
is_activeimage_url
selling_ward_codewebsite_url
opening_hours

Product Management

// app/actions/seller.ts

// List products
export async function getSellerProductsAction()

// Get single product
export async function getProductByIdAction(productId: string)

// Create product
export async function createProductAction(prevState, formData: FormData)

// Update product
export async function updateProductAction(formData: FormData)

// Delete product
export async function deleteProductAction(productId: string)

Order Management

// Get seller's orders
export async function getSellerOrdersAction()

// Update order status
export async function updateOrderStatusAction(orderId: string, newStatus: string)

// Get payment stats
export async function getSellerPaymentStatsAction()

Payment Integration

Paystack Split Payments

Sellers receive payments directly via Paystack subaccounts:
Customer Payment

  Paystack

┌─────────────────┐
│ Platform Fee 5% │ → Visita
└─────────────────┘
┌─────────────────┐
│ Seller 95%      │ → Seller's Bank Account
└─────────────────┘

Creating an Order with Payment

export async function createOrderAndInitializePaymentAction(
  items: { productId: string, quantity: number }[],
  storeId: string,
  shippingAddress?: {
    street: string;
    city: string;
    province: string;
    postalCode: string;
    phone: string;
  }
)
Flow:
  1. Create order record with pending status
  2. Initialize Paystack payment with seller’s subaccount
  3. Return authorization_url for customer redirect
  4. Webhook updates order status on payment success

Verifying Payment

export async function verifyPaymentAction(reference: string)
Called after Paystack redirect to confirm payment status.

Ward Context

Home Ward vs Selling Ward

ConceptDescription
Home WardWhere the business is physically located (from user profile)
Selling WardWhere products appear in marketplace (chosen by seller)
This allows:
  • A bakery in Ward A to sell to customers in Ward B
  • Mobile vendors to choose their selling area
  • Service businesses to define their coverage

Setting Selling Ward

// In StoreProfileInput
sellingWardCode: z.string().optional()
When sellingWardCode differs from homeWardCode, products appear in both ward marketplaces.

Seller Dashboard Routes

/seller                    → Dashboard overview
/seller/store              → Store settings
/seller/products           → Product management
/seller/products/new       → Add new product
/seller/products/[id]      → Edit product
/seller/onboarding         → New seller setup

Vacation Mode

Sellers can temporarily disable their store:
export async function toggleVacationModeAction(
  storeId: string,
  isActive: boolean
): Promise<{ success: boolean; error?: string }>
When is_active = false:
  • Products don’t appear in marketplace
  • Store shows “Temporarily Closed” badge
  • Existing orders can still be fulfilled

Input Validation

Store updates use Zod schemas for validation:
const StoreProfileSchema = z.object({
  storeId: z.string().uuid(),
  name: z.string().min(2, "Store name must be at least 2 characters"),
  description: z.string().optional(),
  category: z.string().optional(),
  coverImageUrl: z.string().url().optional().or(z.literal("")),
  sellingWardCode: z.string().optional(),
  publicPhone: z.string().optional().refine(
    (val) => !val || PhoneUtils.isValidSAPhone(val),
    "Invalid South African phone number"
  ),
  publicEmail: z.string().email().optional().or(z.literal("")),
  paystackSubaccountCode: z.string().optional(),
  currency: z.enum(["ZAR", "USD", "EUR", "GBP"]).default("ZAR"),
  isActive: z.boolean().default(true),
});


Status: Active
Last Updated: January 2026