Skip to main content

Overview

The Store Operations API handles the complete store lifecycle including registration, updates, approval workflows, and wallet initialization. This system manages the onboarding process for new merchants and provides administrative tools for store management. Location: convex/stores/operations.ts

Store Lifecycle

1

Store Registration

Merchant registers their store with basic information and documents
2

Document Review

Admin reviews trade license and store information
3

Approval/Rejection

Admin approves or rejects the store application
4

Wallet Initialization

Approved stores get a wallet for payment processing
5

Store Goes Live

Store can start selling products to customers

Register Store

Register a new store for the authenticated user.
name
string
required
Store name for public display
primaryCategory
Id<'categories'>
required
Primary business category ID
logoId
Id<'_storage'>
Store logo image storage ID
coverImageId
Id<'_storage'>
Store cover/banner image storage ID
tradeLicenseId
Id<'_storage'>
required
Trade license document storage ID
const storeId = await convex.mutation(api.stores.operations.registerStore, {
  name: "Mario's Pizza",
  primaryCategory: "k123456789", // Food category ID
  logoId: "s123456789", // storage ID for logo
  coverImageId: "s987654321", // storage ID for cover image
  tradeLicenseId: "s456789123" // storage ID for trade license
});
"j123456789"

Update Store

Update store information for the authenticated store owner.
name
string
Updated store name
primaryCategory
Id<'categories'>
Updated primary category
logoId
Id<'_storage'>
Updated store logo
coverImageId
Id<'_storage'>
Updated cover image
tradeLicenseId
Id<'_storage'>
Updated trade license
status
'pending' | 'approved' | 'rejected'
Store status (admin only)
statusReason
string
Status reason or notes
settings
object
Store operational settings including delivery, pickup, and live status
bankAccount
object
Bank account details for payouts
address
object
Store physical address and location details
const updatedStore = await convex.mutation(api.stores.operations.updateStore, {
  name: "Mario's Authentic Pizza",
  settings: {
    deliveryEnabled: true,
    pickupEnabled: true,
    storeLive: true
  },
  bankAccount: {
    IBAN: "AE123456789012345678901",
    bankName: "Emirates NBD",
    bankId: "b123456789",
    accountHolderName: "Mario Rossi"
  },
  address: {
    fullAddress: "123 Main Street, Business Bay, Dubai, UAE",
    city: "c123456789", // Dubai city ID
    area: "a987654321", // Business Bay area ID
    flatVilaNumber: "Villa 123",
    buildingNameNumber: "Marina Plaza",
    landmark: "Near Dubai Mall",
    latitude: "25.1972",
    longitude: "55.2744"
  }
});
{
  "_id": "j123456789",
  "name": "Mario's Authentic Pizza",
  "primaryCategory": "k123456789",
  "status": "approved",
  "logoUrl": "https://storage.convex.dev/...",
  "coverImageUrl": "https://storage.convex.dev/...",
  "settings": {
    "deliveryEnabled": true,
    "pickupEnabled": true,
    "storeLive": true
  },
  "bankAccount": {
    "IBAN": "AE123456789012345678901",
    "bankName": "Emirates NBD",
    "accountHolderName": "Mario Rossi"
  },
  "address": {
    "fullAddress": "123 Main Street, Business Bay, Dubai, UAE",
    "city": {
      "_id": "c123456789",
      "name": "Dubai",
      "nameArabic": "دبي"
    },
    "area": {
      "_id": "a987654321",
      "name": "Business Bay",
      "city": "c123456789"
    },
    "flatVilaNumber": "Villa 123",
    "buildingNameNumber": "Marina Plaza",
    "landmark": "Near Dubai Mall",
    "latitude": "25.1972",
    "longitude": "55.2744"
  },
  "_creationTime": 1640995200000
}

Update Store Address

Update only the address information for the authenticated store owner.
fullAddress
string
Complete formatted address string
city
Id<'cities'>
City ID reference
area
Id<'areas'>
Area/district ID reference
flatVilaNumber
string
Flat or villa number
buildingNameNumber
string
Building name or number
landmark
string
Nearby landmark for easier location
latitude
string
GPS latitude coordinate
longitude
string
GPS longitude coordinate
const addressResult = await convex.mutation(api.stores.operations.updateStoreAddress, {
  fullAddress: "456 New Address Street, Marina, Dubai, UAE",
  city: "c123456789", // Dubai city ID
  area: "a555666777", // Marina area ID
  flatVilaNumber: "Apartment 204",
  buildingNameNumber: "Ocean View Tower",
  landmark: "Next to Marina Mall",
  latitude: "25.0657",
  longitude: "55.1713"
});
{
  "_id": "j123456789",
  "address": {
    "fullAddress": "456 New Address Street, Marina, Dubai, UAE",
    "city": {
      "_id": "c123456789",
      "name": "Dubai",
      "nameArabic": "دبي"
    },
    "area": {
      "_id": "a555666777",
      "name": "Marina",
      "city": "c123456789"
    },
    "flatVilaNumber": "Apartment 204",
    "buildingNameNumber": "Ocean View Tower",
    "landmark": "Next to Marina Mall",
    "latitude": "25.0657",
    "longitude": "55.1713"
  }
}

Approve Store (Admin)

Admin function to approve a store application and initialize its wallet.
storeId
Id<'stores'>
required
Store ID to approve
statusReason
string
Reason for approval (optional)
const approvalResult = await convex.mutation(api.stores.operations.approveStore, {
  storeId: "j123456789",
  statusReason: "All documents verified and approved"
});
{
  "storeId": "j123456789",
  "walletId": "w123456789",
  "message": "Store approved successfully and wallet initialized",
  "notificationSent": true
}

Reject Store (Admin)

Admin function to reject a store application.
storeId
Id<'stores'>
required
Store ID to reject
rejectionReason
string
required
Detailed reason for rejection
await convex.mutation(api.stores.operations.rejectStore, {
  storeId: "j123456789",
  rejectionReason: "Trade license document is expired. Please upload a valid license."
});
{
  "storeId": "j123456789",
  "message": "Store rejected successfully",
  "notificationSent": true
}

Initialize Store Wallet

Initialize wallet for an existing approved store.
storeId
Id<'stores'>
required
Store ID to initialize wallet for
const walletResult = await convex.mutation(api.stores.operations.initializeExistingStoreWallet, {
  storeId: "j123456789"
});
{
  "walletId": "w123456789",
  "message": "Wallet initialized successfully for store"
}

Bulk Initialize Wallets (Admin)

Admin function to bulk initialize wallets for all approved stores without wallets.
const bulkResult = await convex.mutation(api.stores.operations.bulkInitializeWallets);
{
  "processedStores": 25,
  "walletsCreated": 23,
  "errors": [
    {
      "storeId": "j987654321",
      "storeName": "Test Store",
      "error": "Store not approved"
    },
    {
      "storeId": "j555666777",
      "storeName": "Another Store",
      "error": "Wallet already exists"
    }
  ]
}

Store Registration Form

Complete store registration component:
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { api } from './convex/_generated/api';

interface StoreRegistrationProps {
  onSuccess?: (storeId: string) => void;
}

function StoreRegistrationForm({ onSuccess }: StoreRegistrationProps) {
  const [formData, setFormData] = useState({
    name: '',
    primaryCategory: '',
    logoFile: null as File | null,
    coverImageFile: null as File | null,
    tradeLicenseFile: null as File | null
  });
  const [uploading, setUploading] = useState(false);
  const [errors, setErrors] = useState<Record<string, string>>({});

  const categories = useQuery(api.shared.categories.getParents);
  const generateUploadUrl = useMutation(api.shared.utils.generateUploadUrl);
  const registerStore = useMutation(api.stores.operations.registerStore);

  const uploadFile = async (file: File): Promise<string> => {
    const uploadUrl = await generateUploadUrl();
    const response = await fetch(uploadUrl, {
      method: 'POST',
      body: file
    });
    const { storageId } = await response.json();
    return storageId;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setUploading(true);
    setErrors({});

    try {
      // Validation
      const newErrors: Record<string, string> = {};
      if (!formData.name.trim()) newErrors.name = 'Store name is required';
      if (!formData.primaryCategory) newErrors.primaryCategory = 'Category is required';
      if (!formData.tradeLicenseFile) newErrors.tradeLicense = 'Trade license is required';

      if (Object.keys(newErrors).length > 0) {
        setErrors(newErrors);
        return;
      }

      // Upload files
      const tradeLicenseId = await uploadFile(formData.tradeLicenseFile!);
      const logoId = formData.logoFile ? await uploadFile(formData.logoFile) : undefined;
      const coverImageId = formData.coverImageFile ? await uploadFile(formData.coverImageFile) : undefined;

      // Register store
      const storeId = await registerStore({
        name: formData.name.trim(),
        primaryCategory: formData.primaryCategory,
        logoId,
        coverImageId,
        tradeLicenseId
      });

      onSuccess?.(storeId);

    } catch (error) {
      console.error('Store registration failed:', error);
      setErrors({ submit: 'Registration failed. Please try again.' });
    } finally {
      setUploading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="max-w-2xl mx-auto space-y-6">
      <div className="bg-blue-50 p-4 rounded-lg">
        <h2 className="text-xl font-bold text-blue-900 mb-2">Register Your Store</h2>
        <p className="text-blue-700 text-sm">
          Complete this form to register your store. All information will be reviewed by our team.
        </p>
      </div>

      {/* Store Name */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Store Name <span className="text-red-500">*</span>
        </label>
        <input
          type="text"
          value={formData.name}
          onChange={(e) => {
            setFormData(prev => ({ ...prev, name: e.target.value }));
            setErrors(prev => ({ ...prev, name: '' }));
          }}
          placeholder="Enter your store name"
          className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          disabled={uploading}
        />
        {errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
      </div>

      {/* Primary Category */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Primary Category <span className="text-red-500">*</span>
        </label>
        <select
          value={formData.primaryCategory}
          onChange={(e) => {
            setFormData(prev => ({ ...prev, primaryCategory: e.target.value }));
            setErrors(prev => ({ ...prev, primaryCategory: '' }));
          }}
          className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          disabled={uploading}
        >
          <option value="">Select a category</option>
          {categories?.map((category) => (
            <option key={category._id} value={category._id}>
              {category.name}
            </option>
          ))}
        </select>
        {errors.primaryCategory && <p className="text-red-500 text-sm mt-1">{errors.primaryCategory}</p>}
      </div>

      {/* Logo Upload */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Store Logo (Optional)
        </label>
        <input
          type="file"
          accept="image/*"
          onChange={(e) => setFormData(prev => ({ ...prev, logoFile: e.target.files?.[0] || null }))}
          className="w-full p-3 border border-gray-300 rounded-lg"
          disabled={uploading}
        />
        <p className="text-xs text-gray-500 mt-1">
          Recommended: Square image, minimum 200x200px
        </p>
      </div>

      {/* Cover Image Upload */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Cover Image (Optional)
        </label>
        <input
          type="file"
          accept="image/*"
          onChange={(e) => setFormData(prev => ({ ...prev, coverImageFile: e.target.files?.[0] || null }))}
          className="w-full p-3 border border-gray-300 rounded-lg"
          disabled={uploading}
        />
        <p className="text-xs text-gray-500 mt-1">
          Recommended: 1200x400px banner image
        </p>
      </div>

      {/* Trade License Upload */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          Trade License <span className="text-red-500">*</span>
        </label>
        <input
          type="file"
          accept=".pdf,.jpg,.jpeg,.png"
          onChange={(e) => {
            setFormData(prev => ({ ...prev, tradeLicenseFile: e.target.files?.[0] || null }));
            setErrors(prev => ({ ...prev, tradeLicense: '' }));
          }}
          className="w-full p-3 border border-gray-300 rounded-lg"
          disabled={uploading}
        />
        <p className="text-xs text-gray-500 mt-1">
          Upload a clear copy of your trade license (PDF, JPG, PNG)
        </p>
        {errors.tradeLicense && <p className="text-red-500 text-sm mt-1">{errors.tradeLicense}</p>}
      </div>

      {/* Submit Button */}
      <div>
        <button
          type="submit"
          disabled={uploading}
          className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
        >
          {uploading ? (
            <>
              <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
              </svg>
              Registering Store...
            </>
          ) : (
            'Register Store'
          )}
        </button>
      </div>

      {errors.submit && (
        <div className="bg-red-50 p-4 rounded-lg">
          <p className="text-red-700 text-sm">{errors.submit}</p>
        </div>
      )}

      <div className="bg-gray-50 p-4 rounded-lg">
        <h3 className="font-medium text-gray-900 mb-2">What happens next?</h3>
        <ol className="text-sm text-gray-600 space-y-1 list-decimal list-inside">
          <li>Your application will be reviewed by our team</li>
          <li>We'll verify your trade license and business details</li>
          <li>You'll receive approval/rejection notification within 24-48 hours</li>
          <li>Once approved, you can start adding products and go live</li>
        </ol>
      </div>
    </form>
  );
}

export default StoreRegistrationForm;

Store Status Management

Store applications go through several status states:

Pending

Initial status when store is registered. Awaiting admin review.

Under Review

Admin is actively reviewing documents and store information.

Approved

Store is approved and can start selling. Wallet is automatically initialized.

Rejected

Store application rejected. Reason provided for resubmission.

Admin Review Process

For administrators reviewing store applications:
// Review and approve a store
const handleApproval = async (storeId: string) => {
  try {
    const result = await convex.mutation(api.stores.operations.approveStore, {
      storeId,
      statusReason: "All documents verified. Welcome to the marketplace!"
    });
    
    console.log(`Store approved: ${result.storeId}`);
    console.log(`Wallet created: ${result.walletId}`);
    
    // Send notification to store owner
    await convex.action(api.shared.notifications.sendNotificationToUser, {
      userId: storeOwnerId,
      title: "🎉 Store Approved!",
      body: "Your store has been approved and is now live",
      data: {
        type: "store_status",
        storeId: result.storeId,
        status: "approved"
      }
    });
    
  } catch (error) {
    console.error('Approval failed:', error);
  }
};

// Review and reject a store
const handleRejection = async (storeId: string, reason: string) => {
  try {
    await convex.mutation(api.stores.operations.rejectStore, {
      storeId,
      rejectionReason: reason
    });
    
    // Send notification to store owner
    await convex.action(api.shared.notifications.sendNotificationToUser, {
      userId: storeOwnerId,
      title: "Store Application Update",
      body: "Your store application needs attention",
      data: {
        type: "store_status",
        storeId,
        status: "rejected"
      }
    });
    
  } catch (error) {
    console.error('Rejection failed:', error);
  }
};

Error Handling

Status Code: 400
{
  "error": "User already has a registered store",
  "existingStoreId": "j123456789"
}
Status Code: 404
{
  "error": "Primary category not found",
  "categoryId": "k123456789"
}
Status Code: 400
{
  "error": "Trade license document is required for store registration"
}
Status Code: 404
{
  "error": "Store not found or access denied",
  "storeId": "j123456789"
}
Store registration requires a valid trade license document. The approval process typically takes 24-48 hours for document verification.
Upload high-quality images for better store presentation. Square logos (200x200px minimum) and banner cover images (1200x400px) work best.