uperNova is shipping the full Cover & Divider Gallery bundle with:

Designer upload + approval path (ties into your creator flow)

Buyer gallery with watermarked previews

Stripe Checkout (Prices/Products) + webhook to mark purchases paid

Filters & categories on the gallery

File types: PPTX, Keynote (.key or .zip), Google Slides link (editable/duplicate link)

Schema, routes, storage helpers, frontend pages/components, seed + Stripe bootstrap script

Copy/paste these and you’re unblocked to upload your first 20 samples.

0) ENV

Add these to your env:

STRIPE_SECRET_KEY=sk_live_or_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
APP_BASE_URL=https://your-domain.tld

1) Database (SQL, Postgres)
-- 01_cover_templates.sql
CREATE TABLE IF NOT EXISTS cover_templates (
  id              TEXT PRIMARY KEY,
  creator_id      TEXT,                       -- nullable for house templates
  title           TEXT NOT NULL,
  category        TEXT NOT NULL,              -- business|minimal|creative|corporate|bold|tech|…
  tags            TEXT[] DEFAULT '{}',
  price_cents     INTEGER NOT NULL DEFAULT 1200,
  currency        TEXT NOT NULL DEFAULT 'usd',
  preview_image_url  TEXT NOT NULL,           -- thumbnail / hero
  pptx_url        TEXT,                       -- direct download URL
  keynote_url     TEXT,                       -- .key or .zip URL
  gslides_url     TEXT,                       -- "Use template" or view link
  download_bundle_url TEXT,                   -- optional: zip incl. variants
  is_active       BOOLEAN NOT NULL DEFAULT TRUE,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_ct_active ON cover_templates(is_active);
CREATE INDEX IF NOT EXISTS idx_ct_category ON cover_templates(category);
CREATE INDEX IF NOT EXISTS idx_ct_creator ON cover_templates(creator_id);

-- 02_cover_purchases.sql
CREATE TABLE IF NOT EXISTS cover_purchases (
  id                TEXT PRIMARY KEY,
  user_id           TEXT NOT NULL,
  template_id       TEXT NOT NULL REFERENCES cover_templates(id) ON DELETE CASCADE,
  custom_image_url  TEXT,
  status            TEXT NOT NULL DEFAULT 'pending',   -- pending|paid|delivered|failed
  download_url      TEXT,                               -- usually pptx/key/zip (or bundle)
  stripe_session_id TEXT,
  stripe_payment_intent TEXT,
  currency          TEXT NOT NULL DEFAULT 'usd',
  amount_cents      INTEGER NOT NULL DEFAULT 0,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_cp_user ON cover_purchases(user_id);
CREATE INDEX IF NOT EXISTS idx_cp_tpl ON cover_purchases(template_id);
CREATE INDEX IF NOT EXISTS idx_cp_status ON cover_purchases(status);

2) Storage helpers

server/storage/coverTemplates.ts

import crypto from "crypto";
import { db } from "../db"; // swap for your client

export type CoverTemplate = {
  id: string; creator_id?: string | null; title: string; category: string; tags?: string[];
  price_cents: number; currency: string; preview_image_url: string;
  pptx_url?: string | null; keynote_url?: string | null; gslides_url?: string | null;
  download_bundle_url?: string | null; is_active: boolean; created_at: string;
};

export async function listCoverTemplates(opts: { q?: string; category?: string; min?: number; max?: number } = {}) {
  let q = db("cover_templates").where({ is_active: true });
  if (opts.category) q = q.andWhere("category", opts.category);
  if (opts.min != null) q = q.andWhere("price_cents", ">=", opts.min);
  if (opts.max != null) q = q.andWhere("price_cents", "<=", opts.max);
  if (opts.q) q = q.andWhereILike("title", `%${opts.q}%`);
  return q.orderBy("created_at", "desc");
}

export async function getCoverTemplate(id: string) {
  return db("cover_templates").where({ id }).first();
}

export async function createCoverTemplate(input: Partial<CoverTemplate>) {
  const id = input.id ?? crypto.randomUUID();
  await db("cover_templates").insert({ id, ...input, is_active: false }); // default inactive (await approval)
  return await getCoverTemplate(id);
}

export async function updateCoverTemplate(id: string, patch: Partial<CoverTemplate>) {
  await db("cover_templates").where({ id }).update(patch);
  return await getCoverTemplate(id);
}

export async function createPurchase(rec: {
  user_id: string; template_id: string; custom_image_url?: string | null;
  amount_cents: number; currency: string; stripe_session_id?: string | null;
}) {
  const id = crypto.randomUUID();
  await db("cover_purchases").insert({
    id, ...rec, status: "pending", download_url: null, stripe_payment_intent: null
  });
  return await db("cover_purchases").where({ id }).first();
}

export async function markPurchasePaidBySession(sessionId: string, paymentIntent: string, downloadUrl: string) {
  await db("cover_purchases")
    .where({ stripe_session_id: sessionId })
    .update({ status: "paid", stripe_payment_intent: paymentIntent, download_url: downloadUrl });
  return await db("cover_purchases").where({ stripe_session_id: sessionId }).first();
}

export async function getUserPurchases(userId: string) {
  return db("cover_purchases").where({ user_id: userId }).orderBy("created_at", "desc");
}

3) Stripe integration

Install (server):

npm i stripe


server/stripe/coverCheckout.ts

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" });

// Returns a Price (creates Product/Price if missing)
export async function ensurePriceForTemplate(tplId: string, title: string, amount_cents: number, currency: string) {
  const product = await stripe.products.create({
    name: `Cover Template • ${title}`,
    id: `ct_${tplId}`, // deterministic
  }).catch(async (e) => {
    if (e.raw?.code === "resource_already_exists") {
      return await stripe.products.retrieve(`ct_${tplId}`);
    }
    throw e;
  });

  // Try to find existing active price with same unit_amount
  const prices = await stripe.prices.list({ product: product.id, active: true, type: "one_time", limit: 100 });
  const existing = prices.data.find(p => p.unit_amount === amount_cents && p.currency === currency);
  if (existing) return existing;

  return await stripe.prices.create({
    product: product.id,
    unit_amount: amount_cents,
    currency,
  });
}

export async function createCheckoutSession(args: {
  priceId: string; successUrl: string; cancelUrl: string;
  metadata?: Record<string, string>;
}) {
  return await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: args.priceId, quantity: 1 }],
    success_url: args.successUrl,
    cancel_url: args.cancelUrl,
    metadata: args.metadata,
  });
}


Webhook (marks purchase paid):
server/routes.stripeWebhook.ts

import express from "express";
import Stripe from "stripe";
import { markPurchasePaidBySession, getCoverTemplate } from "../storage/coverTemplates";
const router = express.Router();

router.post("/api/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const sig = req.headers["stripe-signature"];
  const whSecret = process.env.STRIPE_WEBHOOK_SECRET!;
  let event: Stripe.Event;

  try {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-06-20" });
    event = stripe.webhooks.constructEvent(req.body, sig as string, whSecret);
  } catch (err: any) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  try {
    if (event.type === "checkout.session.completed") {
      const session = event.data.object as Stripe.Checkout.Session;
      const tplId = session.metadata?.templateId!;
      const customImageUrl = session.metadata?.customImageUrl || null;

      const tpl = await getCoverTemplate(tplId);
      const download = tpl?.download_bundle_url || tpl?.pptx_url || tpl?.keynote_url || tpl?.gslides_url || "";
      await markPurchasePaidBySession(session.id, session.payment_intent as string, download);
    }
  } catch (e) {
    console.error(e);
  }
  res.json({ received: true });
});

export default router;


Wire routes (server index):

import coverTemplatesRouter from "./routes.coverTemplates";
import stripeWebhookRouter from "./routes.stripeWebhook";
app.use("/api/stripe/webhook", stripeWebhookRouter); // raw body route
app.use(express.json());                               // after webhook
app.use(coverTemplatesRouter);

4) API routes (gallery, purchase, designer upload)

server/routes.coverTemplates.ts

import express from "express";
import {
  listCoverTemplates, getCoverTemplate, createCoverTemplate,
  createPurchase
} from "../storage/coverTemplates";
import { ensurePriceForTemplate, createCheckoutSession } from "../stripe/coverCheckout";

const router = express.Router();

// middlewares (replace with your real ones)
const authenticate = (req: any, res: any, next: any) => { if (!req.user) return res.status(401).json({ error: "auth" }); next(); };
const authenticateAdmin = (req: any, res: any, next: any) => next(); // your admin check

// Gallery with filters
router.get("/api/cover-templates", async (req, res) => {
  const q = (req.query.q as string) || undefined;
  const category = (req.query.category as string) || undefined;
  const min = req.query.min ? parseInt(req.query.min as string, 10) : undefined;
  const max = req.query.max ? parseInt(req.query.max as string, 10) : undefined;

  const rows = await listCoverTemplates({ q, category, min, max });
  res.json(rows);
});

router.get("/api/cover-templates/:id", async (req, res) => {
  const tpl = await getCoverTemplate(req.params.id);
  if (!tpl || !tpl.is_active) return res.status(404).json({ error: "Not found" });
  res.json(tpl);
});

// Designer creates a template (creator upload) — admin will activate it later
router.post("/api/creator/cover-templates", authenticate, async (req: any, res) => {
  const { title, category, tags, price_cents, preview_image_url, pptx_url, keynote_url, gslides_url, download_bundle_url } = req.body || {};
  if (!title || !category || !preview_image_url) return res.status(400).json({ error: "Missing fields" });

  const rec = await createCoverTemplate({
    creator_id: req.user.id,
    title, category,
    tags: Array.isArray(tags) ? tags : [],
    price_cents: price_cents ?? 1200,
    currency: "usd",
    preview_image_url,
    pptx_url, keynote_url, gslides_url, download_bundle_url,
    is_active: false
  });
  res.json({ success: true, template: rec });
});

// Checkout: Buy As-Is (or with custom image)
router.post("/api/cover-templates/:id/checkout", authenticate, async (req: any, res) => {
  const tpl = await getCoverTemplate(req.params.id);
  if (!tpl || !tpl.is_active) return res.status(404).json({ error: "Not found" });

  const { customImageUrl } = req.body || {};
  const price = await ensurePriceForTemplate(tpl.id, tpl.title, tpl.price_cents, tpl.currency);

  const successUrl = `${process.env.APP_BASE_URL}/business-assets/templates/cover-dividers?success=true`;
  const cancelUrl = `${process.env.APP_BASE_URL}/business-assets/templates/cover-dividers?cancel=true`;

  const session = await createCheckoutSession({
    priceId: price.id,
    successUrl,
    cancelUrl,
    metadata: { templateId: tpl.id, customImageUrl: customImageUrl || "" }
  });

  await createPurchase({
    user_id: req.user.id,
    template_id: tpl.id,
    custom_image_url: customImageUrl || null,
    amount_cents: tpl.price_cents,
    currency: tpl.currency,
    stripe_session_id: session.id
  });

  res.json({ checkoutUrl: session.url });
});

export default router;

5) Frontend – Gallery page (with filters + watermark)

Replace your generator page with this file.

client/src/pages/BusinessAssets/CoverDividerTemplates.tsx

import React, { useMemo, useState } from "react";
import { DashboardTemplatePage } from "@/components/DashboardTemplatePage";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog } from "@/components/ui/dialog";

type CoverTemplate = {
  id: string; title: string; category: string; tags?: string[];
  price_cents: number; currency: string; preview_image_url: string;
  pptx_url?: string; keynote_url?: string; gslides_url?: string; download_bundle_url?: string;
};

const fmt = (c: number, curr = "usd") => `$${(c/100).toFixed(2)}`;

export default function CoverDividerTemplates() {
  const [q, setQ] = useState("");
  const [category, setCategory] = useState<string>("all");
  const [priceMin, setPriceMin] = useState<string>("");
  const [priceMax, setPriceMax] = useState<string>("");

  const { data, isLoading, refetch } = useQuery({
    queryKey: ["cover-templates", q, category, priceMin, priceMax],
    queryFn: async () => {
      const params = new URLSearchParams();
      if (q) params.set("q", q);
      if (category && category !== "all") params.set("category", category);
      if (priceMin) params.set("min", String(Math.max(0, parseInt(priceMin) * 100)));
      if (priceMax) params.set("max", String(Math.max(0, parseInt(priceMax) * 100)));
      const res = await fetch(`/api/cover-templates?${params.toString()}`);
      if (!res.ok) throw new Error("load");
      return res.json() as Promise<CoverTemplate[]>;
    }
  });

  // modal state
  const [open, setOpen] = useState(false);
  const [sel, setSel] = useState<CoverTemplate | null>(null);
  const [custom, setCustom] = useState<string | null>(null);

  function openModal(t: CoverTemplate) { setSel(t); setCustom(null); setOpen(true); }

  async function onPickImage(e: React.ChangeEvent<HTMLInputElement>) {
    const f = e.target.files?.[0]; if (!f) return;
    const dataUrl = await new Promise<string>((resolve, reject) => {
      const r = new FileReader(); r.onload = () => resolve(r.result as string); r.onerror = reject; r.readAsDataURL(f);
    });
    setCustom(dataUrl);
  }

  async function checkout(id: string, customImageUrl?: string) {
    const res = await fetch(`/api/cover-templates/${id}/checkout`, {
      method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include",
      body: JSON.stringify({ customImageUrl })
    });
    if (!res.ok) return toast.error("Checkout failed");
    const j = await res.json();
    window.location.href = j.checkoutUrl;
  }

  return (
    <DashboardTemplatePage title="Cover & Divider Templates">
      <div className="space-y-6">
        <div className="bg-card p-6 rounded-lg border">
          <h2 className="text-xl font-semibold">Curated, editable covers & dividers</h2>
          <p className="text-muted-foreground">Choose a template. Buy as-is or swap one image. Files open in PowerPoint, Keynote, or Google Slides.</p>
        </div>

        {/* Filters */}
        <div className="rounded-lg border p-4 grid grid-cols-1 md:grid-cols-4 gap-3">
          <div className="md:col-span-2">
            <Input placeholder="Search titles…" value={q} onChange={e=>setQ(e.target.value)} />
          </div>
          <Select value={category} onValueChange={setCategory}>
            <SelectTrigger><SelectValue placeholder="Category" /></SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All categories</SelectItem>
              <SelectItem value="business">Business</SelectItem>
              <SelectItem value="minimal">Minimal</SelectItem>
              <SelectItem value="creative">Creative</SelectItem>
              <SelectItem value="corporate">Corporate</SelectItem>
              <SelectItem value="bold">Bold</SelectItem>
              <SelectItem value="tech">Tech</SelectItem>
            </SelectContent>
          </Select>
          <div className="flex gap-2">
            <Input placeholder="Min $" value={priceMin} onChange={e=>setPriceMin(e.target.value)} />
            <Input placeholder="Max $" value={priceMax} onChange={e=>setPriceMax(e.target.value)} />
          </div>
          <div className="md:col-span-4 flex justify-end">
            <Button onClick={()=>refetch()} disabled={isLoading}>Apply</Button>
          </div>
        </div>

        {/* Gallery */}
        <section className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6">
          {(isLoading ? Array.from({length:8},(_,i)=>({id:`s${i}`} as any)) : (data||[])).map((t: CoverTemplate) => (
            <button key={t.id} onClick={()=>!isLoading && openModal(t)} className="text-left rounded-lg border overflow-hidden hover:shadow transition group">
              <div className="relative aspect-[4/3] bg-muted">
                {!isLoading && (
                  <>
                    <img src={t.preview_image_url} alt={t.title} className="w-full h-full object-cover" />
                    {/* Watermark overlay for gallery: */}
                    <div className="absolute inset-0 grid place-items-center pointer-events-none opacity-60 mix-blend-multiply">
                      <div className="wm-bg text-xs sm:text-sm md:text-base">IBrandBiz • Preview</div>
                    </div>
                  </>
                )}
              </div>
              <div className="p-3">
                <div className="font-medium truncate">{isLoading ? "Loading…" : t.title}</div>
                {!isLoading && <div className="text-xs text-muted-foreground">{t.category} • {fmt(t.price_cents)}</div>}
              </div>
            </button>
          ))}
        </section>

        {/* Detail / Purchase */}
        <Dialog open={open} onOpenChange={setOpen}>
          {sel && (
            <div className="p-0 md:p-6 max-w-4xl w-full">
              <div className="grid md:grid-cols-[1fr,320px] gap-6">
                <div className="rounded-lg border overflow-hidden relative">
                  <img src={custom || sel.preview_image_url} className="w-full h-full object-cover aspect-[16/9]" />
                  {/* Watermark overlay for non-owners */}
                  <div className="absolute inset-0 grid place-items-center pointer-events-none opacity-60 mix-blend-multiply">
                    <div className="wm-bg text-sm md:text-base">IBrandBiz • Preview</div>
                  </div>
                </div>

                <aside className="space-y-4">
                  <div>
                    <div className="text-lg font-semibold">{sel.title}</div>
                    <div className="text-sm text-muted-foreground">{sel.category}</div>
                    <div className="mt-1 text-xl font-bold">{fmt(sel.price_cents)}</div>
                    <div className="text-xs text-muted-foreground mt-2">Includes: PPTX, Keynote, or Google Slides link (editable).</div>
                  </div>

                  <Button className="w-full" onClick={()=>checkout(sel.id)}>Buy As-Is</Button>

                  <div className="text-xs text-muted-foreground text-center">— or —</div>

                  <label className="w-full">
                    <input type="file" accept="image/*" className="hidden" onChange={onPickImage} />
                    <div className="w-full border rounded-lg p-3 text-center cursor-pointer hover:bg-accent">
                      {custom ? "Change Image" : "Upload Image to Swap"}
                    </div>
                  </label>
                  <Button className="w-full" variant="secondary" disabled={!custom} onClick={()=>checkout(sel.id, custom!)}>
                    Buy with My Image
                  </Button>

                  <div className="text-xs text-muted-foreground">
                    You’ll receive editable files. Previews are watermarked until purchase.
                  </div>
                </aside>
              </div>
            </div>
          )}
        </Dialog>
      </div>

      {/* Watermark CSS */}
      <style>{`
        .wm-bg {
          font-weight: 700;
          letter-spacing: 1px;
          color: rgba(0,0,0,.25);
          background-image: repeating-linear-gradient(
            45deg,
            rgba(255,255,255,.25) 0 20px,
            rgba(255,255,255,.15) 20px 40px
          );
          padding: .5rem 1rem;
          border-radius: .5rem;
          transform: rotate(-20deg);
          user-select: none;
        }
      `}</style>
    </DashboardTemplatePage>
  );
}


The watermark overlay is client-side CSS (fast). If you want server-side stamped images later, we can add a /api/preview/:id proxy that composites the text.

6) Creator upload page (so designers can sell)

client/src/pages/Creator/UploadCoverTemplate.tsx

import { useState } from "react";
import { DashboardTemplatePage } from "@/components/DashboardTemplatePage";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";

export default function UploadCoverTemplate() {
  const [title, setTitle] = useState("");
  const [category, setCategory] = useState("business");
  const [price, setPrice] = useState("12");
  const [preview, setPreview] = useState("");
  const [pptx, setPptx] = useState("");
  const [keynote, setKeynote] = useState("");
  const [gslides, setGslides] = useState("");
  const [bundle, setBundle] = useState("");

  async function submit() {
    const res = await fetch("/api/creator/cover-templates", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({
        title, category, price_cents: Math.max(0, parseInt(price) * 100),
        preview_image_url: preview,
        pptx_url: pptx || null,
        keynote_url: keynote || null,
        gslides_url: gslides || null,
        download_bundle_url: bundle || null
      })
    });
    if (!res.ok) return toast.error("Upload failed");
    toast.success("Submitted for review. We’ll activate it soon.");
    setTitle(""); setPreview(""); setPptx(""); setKeynote(""); setGslides(""); setBundle("");
  }

  return (
    <DashboardTemplatePage title="Upload Cover Template">
      <div className="grid md:grid-cols-2 gap-4">
        <div className="space-y-3">
          <Input placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} />
          <Input placeholder="Category (business/minimal/creative/…)" value={category} onChange={e=>setCategory(e.target.value)} />
          <Input placeholder="Price (USD)" value={price} onChange={e=>setPrice(e.target.value)} />
          <Input placeholder="Preview Image URL" value={preview} onChange={e=>setPreview(e.target.value)} />
          <Input placeholder="PPTX URL (optional)" value={pptx} onChange={e=>setPptx(e.target.value)} />
          <Input placeholder="Keynote URL (optional)" value={keynote} onChange={e=>setKeynote(e.target.value)} />
          <Input placeholder="Google Slides link (optional)" value={gslides} onChange={e=>setGslides(e.target.value)} />
          <Input placeholder="Bundle ZIP (optional)" value={bundle} onChange={e=>setBundle(e.target.value)} />
        </div>
        <div className="rounded border p-3">
          <div className="font-medium mb-2">Preview</div>
          <div className="relative aspect-[4/3] bg-muted overflow-hidden">
            {preview && <img src={preview} className="w-full h-full object-cover" />}
            <div className="absolute inset-0 grid place-items-center pointer-events-none opacity-60 mix-blend-multiply">
              <div className="wm-bg text-sm">IBrandBiz • Preview</div>
            </div>
          </div>
          <Button className="mt-4 w-full" onClick={submit}>Submit for Review</Button>
        </div>
      </div>
    </DashboardTemplatePage>
  );
}


Routes (App.tsx):

import UploadCoverTemplate from "@/pages/Creator/UploadCoverTemplate";
<Route path="/creator/upload-cover-template">
  <ProtectedRoute><DashboardTemplatePage title="Upload Cover Template"><UploadCoverTemplate /></DashboardTemplatePage></ProtectedRoute>
</Route>


Link this from My Marketplace → Upload as an option (“Upload Cover Template”).

7) Admin: activation (optional quick endpoint)

If you want a fast toggle:

// In routes.coverTemplates.ts
router.patch("/api/admin/cover-templates/:id/activate", authenticateAdmin, async (req, res) => {
  const { active } = req.body || {};
  await updateCoverTemplate(req.params.id, { is_active: !!active });
  res.json({ success: true });
});


Add a small toggle UI in your Admin Marketplace or create a dedicated admin page list.

8) Seeding (for your first 20)

Run a small script to insert records with your preview image and file links (PPTX/Keynote/Slides). You can keep is_active=false initially and flip them after review.

9) What buyers get

After Stripe marks the session paid (webhook), we set download_url to:

download_bundle_url if present; else pptx_url (PowerPoint), else keynote_url, else gslides_url.

You can include all three in a bundle zip so users receive everything.

10) QA checklist

Gallery loads and filters (q, category, price range) work.

Watermark overlay is visible in gallery & modal.

Checkout launches Stripe; after success, record becomes paid; refresh and verify purchase recorded in DB.

Download opens PPTX/Keynote/Slides link (bundle if available).

Designer upload submits; record appears in DB as inactive; admin can activate.

Only active templates appear publicly.