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 stores | Updated in listings |
|---|
store_name | business_name |
paystack_subaccount_code | description |
currency | category |
is_active | image_url |
selling_ward_code | website_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:
- Create order record with
pending status
- Initialize Paystack payment with seller’s subaccount
- Return
authorization_url for customer redirect
- 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
| Concept | Description |
|---|
| Home Ward | Where the business is physically located (from user profile) |
| Selling Ward | Where 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
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