PROMPT FOR REPLIT — Phase 3 “Creator Marketplace” (MVP)

Goal: Add creator onboarding + uploads + approval + Stripe Connect payouts with single-seller carts. Do not change existing consumer buying flows for IBrandBiz-owned stock.

ENV

Add (or confirm) these:

STRIPE_SECRET_KEY

STRIPE_WEBHOOK_SECRET

PLATFORM_FEE_BPS=3000 // 30% in basis points

CREATOR_MIN_PRICE_CENTS=199

CREATOR_MAX_PRICE_CENTS=4999

Backend
1) Data models (extend your DB)

Create tables/collections:

creators: { id, userId, stripe_account_id, display_name, status: 'pending'|'onboarding'|'active'|'restricted', createdAt }

assets (add columns): { id, creatorId, type:'photo'|'mockup'|'icon', name, price_cents, status:'draft'|'submitted'|'approved'|'rejected', tags[], category, files{preview,original,svg?}, createdAt }

orders, order_items (already exist or add): add creatorId to order_items.

earnings: { id, creatorId, orderItemId, amount_cents, status:'pending'|'paid', createdAt }

2) New routes

routes/creatorConnectRoutes.js

const express = require('express');
const Stripe = require('stripe');
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-06-20' });

// Create (or reuse) a Stripe Connect Express account and return an onboarding link
router.post('/start', async (req, res) => {
  try {
    const userId = req.user?.id || 'anon'; // replace with real auth
    // 1) find or create local creator record
    let creator = await db.creators.findByUserId(userId);
    if (!creator) {
      const account = await stripe.accounts.create({ type: 'express' });
      creator = await db.creators.insert({
        userId, stripe_account_id: account.id, status: 'onboarding', createdAt: new Date()
      });
    }
    // 2) generate onboarding link
    const link = await stripe.accountLinks.create({
      account: creator.stripe_account_id,
      refresh_url: `${process.env.DOMAIN_URL}/creator/onboarding/refresh`,
      return_url: `${process.env.DOMAIN_URL}/creator/onboarding/return`,
      type: 'account_onboarding',
    });
    res.json({ url: link.url });
  } catch (e) {
    console.error(e);
    res.status(500).json({ error: 'Failed to start onboarding' });
  }
});

// Status check (to show dashboard vs. onboarding CTA)
router.get('/me', async (req, res) => {
  const userId = req.user?.id || 'anon';
  const creator = await db.creators.findByUserId(userId);
  if (!creator) return res.json({ exists: false });
  res.json({ exists: true, status: creator.status, stripe_account_id: creator.stripe_account_id });
});

module.exports = router;


routes/creatorAssetsRoutes.js

const express = require('express');
const router = express.Router();
const multer = require('multer');
const upload = multer({ dest: 'uploads/tmp' });

// Create asset (draft) + upload original → reuse your watermark/sharp pipeline
router.post('/assets', upload.single('file'), async (req, res) => {
  const userId = req.user?.id || 'anon';
  const creator = await db.creators.findByUserId(userId);
  if (!creator || creator.status !== 'active') return res.status(403).json({ error: 'Creator not active' });

  const price = Math.max(
    Number(process.env.CREATOR_MIN_PRICE_CENTS),
    Math.min(Number(req.body.price_cents || 0), Number(process.env.CREATOR_MAX_PRICE_CENTS))
  );

  // Save original + generate preview/watermark using your existing helpers
  const { previewUrl, originalPath, svgPath } = await mediaPipeline.processCreatorUpload({
    file: req.file, type: req.body.type || 'photo'
  });

  const asset = await db.assets.insert({
    creatorId: creator.id,
    type: req.body.type || 'photo',
    name: req.body.name || 'Untitled',
    price_cents: price,
    status: 'submitted',
    tags: req.body.tags ? req.body.tags.split(',') : [],
    category: req.body.category || null,
    files: { preview: previewUrl, original: originalPath, svg: svgPath || null },
    createdAt: new Date(),
  });

  res.json({ ok: true, asset });
});

// Admin approval
router.post('/assets/:id/approve', async (req, res) => {
  // TODO auth guard
  await db.assets.updateStatus(req.params.id, 'approved');
  res.json({ ok: true });
});
router.post('/assets/:id/reject', async (req, res) => {
  await db.assets.updateStatus(req.params.id, 'rejected');
  res.json({ ok: true });
});

// Public list for marketplace (approved only)
router.get('/market', async (req, res) => {
  const items = await db.assets.listApproved({ type: req.query.type });
  res.json({ items });
});

module.exports = router;

3) Checkout changes (single-seller cart + Connect)

In your routes/checkoutRoutes.js, handle creator items:

Enforce cart contains one creatorId if any creator items are present.

If creator cart: create a Checkout Session with:

line_items as usual

payment_intent_data[transfer_data][destination] = creator.stripe_account_id

payment_intent_data[application_fee_amount] = Math.round(gross * PLATFORM_FEE_BPS/10000)

On webhook, mark licenses + write an earnings row for the creator.

Pseudo-patch inside the loop:

const PLATFORM_FEE_BPS = Number(process.env.PLATFORM_FEE_BPS || 3000);

let destinationAcct = null; // set if creator cart
let creatorId = null;

for (const item of items) {
  if (item.kind === 'creator_asset') {
    const asset = await db.assets.get(item.assetId);
    if (!asset || asset.status !== 'approved') throw new Error('Asset not for sale');
    if (!creatorId) creatorId = asset.creatorId;
    if (creatorId !== asset.creatorId) throw new Error('Cart must contain items from one creator');

    const creator = await db.creators.get(creatorId);
    destinationAcct = creator.stripe_account_id;

    line_items.push({
      price_data: {
        currency: 'usd',
        unit_amount: asset.price_cents,
        product_data: { name: `${asset.name} — by ${creator.display_name}` },
      },
      quantity: item.qty || 1,
    });
  }
  // … keep your existing stock/subscription handling unchanged …
}

const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items,
  success_url: `${domain}${successPath}?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${domain}${cancelPath}`,
  payment_intent_data: destinationAcct ? {
    transfer_data: { destination: destinationAcct },
    application_fee_amount: Math.round(
      line_items.reduce((s, li)=> s + (li.price_data?.unit_amount || 0) * (li.quantity || 1), 0)
      * PLATFORM_FEE_BPS / 10000
    ),
  } : undefined,
  metadata: {
    userId: String(req.user?.id || 'anon'),
    creatorId: creatorId || '',
    assetIds: purchasedAssetIds.join(','),
  },
});


Webhook (routes/stripeWebhook.js): on checkout.session.completed, for each creator item:

Create license rows for the buyer.

Insert earnings entries with the creator’s net (gross − platform fee − Stripe fee if you’re capturing it on platform).
(With destination charges, Stripe pays the creator net and you get your application fee—Stripe already subtracts their fee correctly. You can still record earnings for analytics.)

4) Review queue (moderation)

Add GET /api/admin/review/assets?status=submitted

Add bulk approve/reject endpoints.

Surface “submitted/approved/rejected” in admin UI.

Frontend
1) Creator dashboard (new)

Routes:

/creator → if not active, show “Become a Seller” → POST /api/creator/connect/start and redirect to url.

After return, poll /api/creator/me until status === 'active' to show dashboard.

Pages:

Upload (uses /api/creator/assets form: { file, name, type, price_cents, tags, category })

My Assets (status badge, price, preview)

Earnings (sum of paid/pending)

Guidelines (content policy)

2) Marketplace browsing

New “Marketplace” tabs for Creator Photos / Creator Mockups.

Use /api/creator/market for grids.

“Add to Cart” should tag items as { kind:'creator_asset', assetId, qty:1 }.

Cart guard: if cart already has creator items from a different creator, show a message and offer to “Start a new cart”.

Acceptance criteria (MVP)

A non-admin user can:

Click Become a Seller → complete Stripe onboarding → return with status active.

Upload an asset → it appears as Submitted.

Admin approves → it appears in Marketplace.

A buyer purchases the asset → receives license/original download.

Stripe shows a payment with application fee to you and funds to creator’s account.

Existing IBrandBiz stock (your own library) remains unchanged.

Membership credits do not apply to creator items (for now).

Notes & guardrails

Legal: add Creator Terms, Content Policy, DMCA page (we can draft later).

Refunds/Disputes: with destination charges, Stripe handles correct netting; make sure your webhook marks licenses refunded on charge.refunded.

Content safety: run uploaded files through your current watermark/preview pipeline + virus scan (if available).

Duplicates: hash originals to catch dupes before approval.