Skip to main content

Marketplace Redesign Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Joblogic-mirrored marketplace UX on market.odontox.io plus a click-to-open Marketplace flyout in go.odontox.io’s tenant sidebar (admin/owner only), with image upload + module-tagging + dependency tagging in the superadmin editor. Architecture: One additive Postgres migration adds related_modules and dependencies to marketplace_listings. Server route includes them in responses; new endpoint accepts image uploads to R2. Frontend changes split across two apps: marketplace-app/ gets a hero + carousel + faceted sidebar + sectioned/flat grid + redesigned detail page + new My Apps page + global footer + indigo tokens. ui/ (go.odontox.io) gains a Marketplace nav item + flyout panel. Tech Stack: React + Vite + Tailwind + TanStack Query, Hono on Cloudflare Workers, Drizzle + Postgres (Neon), Cloudflare R2 for image storage, lucide-react icons. Spec: docs/superpowers/specs/2026-05-21-marketplace-redesign-design.md

File Map

Server:
  • Create: server/drizzle/0050_marketplace_related_modules_deps.sql
  • Modify: server/src/schema/marketplace_listings.ts (add two columns)
  • Modify: server/src/routes/marketplace.ts (expose new fields in publicListingShape)
  • Modify: server/src/routes/superadmin/marketplace.ts (accept new fields on update, add upload endpoint)
  • Modify: docs/api-reference.md (document upload endpoint)
ui/ (go.odontox.io):
  • Modify: ui/src/hooks/useNavItems.ts (add Marketplace item for admin/owner)
  • Create: ui/src/components/MarketplaceFlyout.tsx
  • Modify: ui/src/components/appSidebar.tsx (wire flyout to nav item)
  • Modify: ui/src/pages/sign-in.tsx (bump APP_VERSION)
marketplace-app/:
  • Modify: marketplace-app/src/index.css (replace teal tokens with indigo, mirror ui/src/index.css)
  • Modify: marketplace-app/src/components/MarketplaceShell.tsx (Home/My Apps nav links, footer)
  • Create: marketplace-app/src/components/MarketplaceFooter.tsx
  • Create: marketplace-app/src/components/marketplace/MarketplaceHero.tsx
  • Create: marketplace-app/src/components/marketplace/RecommendedForYouRow.tsx
  • Create: marketplace-app/src/components/marketplace/MarketplaceSidebar.tsx
  • Create: marketplace-app/src/components/marketplace/SectionedListings.tsx
  • Modify: marketplace-app/src/pages/MarketplaceIndex.tsx (compose new sections)
  • Modify: marketplace-app/src/pages/MarketplaceDetail.tsx (breadcrumbs + right rail + restructured)
  • Create: marketplace-app/src/components/marketplace/DetailRail.tsx
  • Create: marketplace-app/src/pages/MyApps.tsx
  • Modify: marketplace-app/src/App.tsx (route additions, redirect /requests)
  • Modify: marketplace-app/src/hooks/use-marketplace-listings.ts (add fields to MarketplaceListing type)
  • Modify: marketplace-app/public/hero-mockup.webp (new asset — placeholder OK for v1)
Superadmin editor:
  • Modify: ui/src/components/superadmin/MarketplaceListingEditor.tsx (image upload, related modules multi-select, dependencies chip input)

Files:
  • Create: server/drizzle/0050_marketplace_related_modules_deps.sql
  • Modify: server/src/schema/marketplace_listings.ts
  • Step 1: Write the migration SQL
server/drizzle/0050_marketplace_related_modules_deps.sql:
ALTER TABLE app.marketplace_listings
  ADD COLUMN related_modules text[] NOT NULL DEFAULT '{}',
  ADD COLUMN dependencies text[] NOT NULL DEFAULT '{}';
  • Step 2: Update Drizzle schema to match
In server/src/schema/marketplace_listings.ts, after the securityBadges line and before status, add:
relatedModules: text('related_modules').array().notNull().default(sql`'{}'::text[]`),
dependencies: text('dependencies').array().notNull().default(sql`'{}'::text[]`),
Add import { sql } from 'drizzle-orm'; if not already imported.
  • Step 3: Verify TypeScript compiles
Run: pnpm --filter server typecheck Expected: PASS
  • Step 4: Apply migration to local dev DB only
Run: pnpm --filter server drizzle:migrate Expected: migration 0050 applied. Do NOT run against prod.
  • Step 5: Commit
git add server/drizzle/0050_marketplace_related_modules_deps.sql server/src/schema/marketplace_listings.ts
git commit -m "feat(marketplace): add related_modules and dependencies columns"

Files:
  • Modify: server/src/routes/marketplace.ts
  • Modify: marketplace-app/src/hooks/use-marketplace-listings.ts
  • Step 1: Verify publicListingShape returns the new fields
Open server/src/routes/marketplace.ts. The current shape spreads all columns except updatedBy and status. Since related_modules and dependencies are now part of the row, they’ll flow through automatically. No code change needed — confirm by reading lines 28-34.
  • Step 2: Add the new fields to the client TypeScript type
In marketplace-app/src/hooks/use-marketplace-listings.ts, add two fields to the MarketplaceListing interface (before subscriptionState):
relatedModules: string[];
dependencies: string[];
Also add them to MarketplaceListingDetail['listing']’s type if it’s a separate declaration — it uses Omit<MarketplaceListing, 'subscriptionState'>, so it picks them up automatically.
  • Step 3: Write a sanity test for the response shape
Create server/src/routes/__tests__/marketplace-listings-shape.test.ts:
import { describe, it, expect } from 'vitest';

describe('marketplace listings response', () => {
  it('always includes relatedModules and dependencies arrays', () => {
    const fakeRow = {
      id: 'x',
      displayName: 'X',
      tagline: '',
      iconKind: 'lucide',
      iconValue: 'Star',
      heroColor: 'indigo',
      longDescriptionMd: '',
      whatYouGet: [],
      screenshots: [],
      faq: [],
      versionLabel: 'v1',
      lastUpdatedAt: new Date(),
      pricingSummary: '',
      category: 'admin',
      securityBadges: [],
      relatedModules: ['appointments'],
      dependencies: ['Core OdontoX System'],
      sortOrder: 0,
      status: 'published',
      createdAt: new Date(),
      updatedAt: new Date(),
      updatedBy: null,
    };
    const { updatedBy, status, ...safe } = fakeRow;
    expect(safe.relatedModules).toEqual(['appointments']);
    expect(safe.dependencies).toEqual(['Core OdontoX System']);
    expect((safe as any).updatedBy).toBeUndefined();
    expect((safe as any).status).toBeUndefined();
  });
});
  • Step 4: Run the test
Run: pnpm --filter server test marketplace-listings-shape Expected: PASS
  • Step 5: Commit
git add server/src/routes/__tests__/marketplace-listings-shape.test.ts marketplace-app/src/hooks/use-marketplace-listings.ts
git commit -m "feat(marketplace): surface related_modules and dependencies on listing API"

Task 3: Image-upload endpoint for superadmin marketplace

Files:
  • Modify: server/src/routes/superadmin/marketplace.ts
  • Modify: docs/api-reference.md
  • Step 1: Inspect the existing R2 service
Read server/src/lib/r2.ts lines 68-120 to confirm R2Service.uploadFile(key, bytes, mime, opts) signature, and server/src/routes/billing.ts lines 2110-2160 to confirm the formData → magic-bytes → uploadFile pattern.
  • Step 2: Write a failing test for the upload endpoint
Create server/src/routes/__tests__/superadmin-marketplace-upload.test.ts:
import { describe, it, expect, vi } from 'vitest';
import { makeApp, signInAsSuperadmin, signInAsAdmin } from '../../testing/test-app';

describe('POST /api/v1/protected/superadmin/marketplace/upload', () => {
  it('returns 403 for non-superadmin', async () => {
    const app = await makeApp();
    const session = await signInAsAdmin(app);
    const form = new FormData();
    form.append('file', new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' }), 'x.png');
    const res = await app.request('/api/v1/protected/superadmin/marketplace/upload', {
      method: 'POST',
      headers: { cookie: session.cookie },
      body: form,
    });
    expect(res.status).toBe(403);
  });

  it('rejects oversized files (>2MB)', async () => {
    const app = await makeApp();
    const session = await signInAsSuperadmin(app);
    const form = new FormData();
    form.append('file', new Blob([new Uint8Array(2 * 1024 * 1024 + 1)]), 'big.png');
    const res = await app.request('/api/v1/protected/superadmin/marketplace/upload', {
      method: 'POST',
      headers: { cookie: session.cookie },
      body: form,
    });
    expect(res.status).toBe(413);
  });

  it('rejects non-image MIME', async () => {
    const app = await makeApp();
    const session = await signInAsSuperadmin(app);
    const form = new FormData();
    form.append('file', new Blob([new Uint8Array([0x25, 0x50, 0x44, 0x46])], { type: 'application/pdf' }), 'x.pdf');
    const res = await app.request('/api/v1/protected/superadmin/marketplace/upload', {
      method: 'POST',
      headers: { cookie: session.cookie },
      body: form,
    });
    expect(res.status).toBe(415);
  });
});
(If test-app.ts helpers don’t match this exactly, adjust to match the existing test patterns under server/src/routes/__tests__/. Read server/src/routes/__tests__/superadmin-marketplace.test.ts for the canonical pattern.)
  • Step 3: Run and confirm failure
Run: pnpm --filter server test superadmin-marketplace-upload Expected: FAIL — route does not exist (404).
  • Step 4: Implement the endpoint
In server/src/routes/superadmin/marketplace.ts, add at the bottom of the route file (before the export):
import { ulid } from 'ulidx'; // already used elsewhere; verify import path matches existing usage
import { getR2Service } from '../../lib/r2';
import { detectImageMime } from '../../lib/image-mime'; // helper already exists; verify path

route.post('/upload', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') throw new AppError('Superadmin only', 403);

    const form = await c.req.formData();
    const file = form.get('file') as File | null;
    if (!file || typeof (file as any).arrayBuffer !== 'function') throw new AppError('Missing file', 400);
    if (file.size > 2 * 1024 * 1024) throw new AppError('Image must be ≤ 2 MB', 413);

    const bytes = new Uint8Array(await file.arrayBuffer());
    if (bytes.byteLength === 0) throw new AppError('Uploaded file is empty', 400);

    const mime = detectImageMime(bytes);
    if (!mime || !['image/png', 'image/jpeg', 'image/webp'].includes(mime)) {
      throw new AppError('Only PNG, JPEG, or WebP', 415);
    }

    const listingId = (form.get('listingId') as string | null) ?? 'unlinked';
    const ext = mime === 'image/png' ? 'png' : mime === 'image/jpeg' ? 'jpg' : 'webp';
    const key = `marketplace/${listingId}/${ulid()}.${ext}`;

    const r2 = getR2Service(c.env as any);
    if (!r2) throw new AppError('R2 storage not configured', 500);
    await r2.uploadFile(key, bytes, mime, {});

    return c.json({ ok: true, key });
  } catch (e) {
    return handleError(e, c);
  }
});
If detectImageMime doesn’t exist, add a small inline helper above the route that checks the first 4 bytes:
  • 89 50 4e 47 → png
  • ff d8 ff → jpeg
  • 52 49 46 46 + bytes 8-11 = 57 45 42 50 → webp
  • Step 5: Run tests
Run: pnpm --filter server test superadmin-marketplace-upload Expected: PASS for all three cases.
  • Step 6: Document the endpoint in docs/api-reference.md
Find the marketplace section in docs/api-reference.md and add:
### POST `/api/v1/protected/superadmin/marketplace/upload`

Upload an image for a marketplace listing (icon or screenshot). Stores in R2 and returns the key.

**Auth:** superadmin

**Body:** `multipart/form-data` with:
- `file` (required) — PNG, JPEG, or WebP, ≤ 2 MB
- `listingId` (optional) — listing id to namespace the key under

**Response:** `{ "ok": true, "key": "marketplace/{listingId}/{ulid}.{ext}" }`

**Errors:**
- 400 — missing or empty file
- 403 — caller is not superadmin
- 413 — file > 2 MB
- 415 — unsupported MIME (not PNG/JPEG/WebP)
  • Step 7: Commit
git add server/src/routes/superadmin/marketplace.ts server/src/routes/__tests__/superadmin-marketplace-upload.test.ts docs/api-reference.md
git commit -m "feat(marketplace): superadmin image-upload endpoint (R2, 2MB cap)"

Files:
  • Modify: ui/src/components/superadmin/MarketplaceListingEditor.tsx
  • Step 1: Read the existing editor (lines 130-300, 400-470) to understand the current screenshot input, form shape, and save mutation.
  • Step 2: Add relatedModules and dependencies to the form type
In the Form type near the top of the file (around line 50), add:
relatedModules: string[];
dependencies: string[];
Wire them into the initial load (defaulting to [] when undefined) and the save payload sent to the server.
  • Step 3: Verify the server PATCH/PUT accepts these fields
Check server/src/routes/superadmin/marketplace.ts for the listing-update handler. If it whitelists fields, add relatedModules and dependencies. If it spreads body into the DB call, no change needed. If you added fields, also write a quick test under __tests__/.
  • Step 4: Replace screenshot R2-key input with file upload
Find the screenshot input section (around line 405-440 in the editor). Replace the text input + “Add screenshot” button with:
<input
  type="file"
  accept="image/png,image/jpeg,image/webp"
  onChange={async (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const fd = new FormData();
    fd.append('file', f);
    fd.append('listingId', form.id);
    const res = await fetch('/api/v1/protected/superadmin/marketplace/upload', {
      method: 'POST',
      body: fd,
      credentials: 'include',
    });
    if (!res.ok) {
      toast.error(await res.text());
      return;
    }
    const { key } = await res.json() as { key: string };
    addScreenshot.mutate({ key, alt: screenshotAlt });
    setScreenshotAlt('');
    (e.target as HTMLInputElement).value = '';
  }}
/>
Keep the screenshotAlt text input as a sibling so users still set alt text before picking the file.
  • Step 5: Do the same for icon when iconKind === 'upload'
Locate the icon section (around line 280 — where iconKind switches between lucide and upload). When upload is selected, render the same file input pattern. On success, set form.iconValue to the returned key.
  • Step 6: Add a “Recommended modules” multi-select
Below the category select, add a multi-select. Use a simple checkbox grid (or import a combobox you already have under ui/src/components/ui/):
import { AVAILABLE_MODULES } from '@/lib/modules'; // create this if not present; mirror server's constants
// ...
<div className="space-y-1">
  <label className="text-sm font-medium">Recommended modules</label>
  <div className="grid grid-cols-2 gap-2 rounded border p-3">
    {AVAILABLE_MODULES.map((m) => {
      const checked = form.relatedModules.includes(m.key);
      return (
        <label key={m.key} className="flex items-center gap-2 text-sm">
          <input
            type="checkbox"
            checked={checked}
            onChange={() => {
              setForm({
                ...form,
                relatedModules: checked
                  ? form.relatedModules.filter((k) => k !== m.key)
                  : [...form.relatedModules, m.key],
              });
            }}
          />
          <span>{m.label}</span>
        </label>
      );
    })}
  </div>
</div>
Create ui/src/lib/modules.ts that re-exports the same constant from shared/ or duplicates it from server/src/constants/modules.ts. Prefer a shared package if the workspace already has one.
  • Step 7: Add a “Dependencies” chip input
Below recommended modules:
<div className="space-y-1">
  <label className="text-sm font-medium">Dependencies</label>
  <div className="flex flex-wrap gap-1 rounded border p-2">
    {form.dependencies.map((d) => (
      <span key={d} className="inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs">
        {d}
        <button type="button" onClick={() => setForm({ ...form, dependencies: form.dependencies.filter((x) => x !== d) })}>
          ×
        </button>
      </span>
    ))}
    <input
      list="dep-suggestions"
      placeholder="Add dependency…"
      className="flex-1 min-w-[120px] bg-transparent text-sm outline-none"
      onKeyDown={(e) => {
        if (e.key === 'Enter' && e.currentTarget.value.trim()) {
          e.preventDefault();
          const v = e.currentTarget.value.trim();
          if (!form.dependencies.includes(v)) {
            setForm({ ...form, dependencies: [...form.dependencies, v] });
          }
          e.currentTarget.value = '';
        }
      }}
    />
    <datalist id="dep-suggestions">
      <option value="Core OdontoX System" />
      <option value="WhatsApp Cloud API" />
      <option value="AI Credits" />
      <option value="Pro plan" />
      <option value="Pro+ plan" />
      <option value="Cloudflare Storage" />
    </datalist>
  </div>
</div>
  • Step 8: Manual smoke
Run: pnpm --filter ui dev. Open the superadmin marketplace listing editor. Confirm:
  • Selecting a file uploads and a thumbnail appears with the returned key.
  • Toggling a recommended module checkbox persists on save.
  • Adding/removing a dependency chip persists on save.
  • Step 9: Commit
git add ui/src/components/superadmin/MarketplaceListingEditor.tsx ui/src/lib/modules.ts server/src/routes/superadmin/marketplace.ts
git commit -m "feat(marketplace): superadmin editor — image upload + related modules + dependencies"

Task 5: Switch marketplace-app to indigo CSS tokens

Files:
  • Modify: marketplace-app/src/index.css
  • Step 1: Diff against ui/src/index.css
Open both files. Copy the :root and .dark block from ui/src/index.css (the section defining --background, --foreground, --primary, etc.) and replace the corresponding block in marketplace-app/src/index.css.
  • Step 2: Grep for hardcoded teal in marketplace-app
Run: grep -rn "teal-\|teal\b\|#0d9488\|#14b8a6\|cyan-" marketplace-app/src/ Expected: list of leaks.
  • Step 3: Replace each leak with semantic tokens
For each match: swap text-teal-*text-primary, bg-teal-*bg-primary, border-teal-*border-primary (or border-accent if it was a soft accent). Keep ListingCard hero color logic that uses the heroColor data field — that’s a per-listing token override, leave it.
  • Step 4: Smoke check both themes
Run: pnpm --filter marketplace-app dev. Visit http://localhost:5174/. Toggle theme. Confirm primary buttons and active nav states are indigo, no teal remains.
  • Step 5: Commit
git add marketplace-app/src/index.css marketplace-app/src/
git commit -m "style(marketplace-app): adopt main app indigo tokens, drop teal"

Files:
  • Create: marketplace-app/src/components/MarketplaceFooter.tsx
  • Modify: marketplace-app/src/components/MarketplaceShell.tsx
  • Step 1: Write the footer
marketplace-app/src/components/MarketplaceFooter.tsx:
import { DASHBOARD_URL } from '@/lib/api-base';

const SUPPORT_URL = 'mailto:[email protected]';
const PRIVACY_URL = 'https://q.odontox.io/privacy';
const TERMS_URL = 'https://q.odontox.io/terms';
const STATUS_URL = 'https://status.odontox.io';

export function MarketplaceFooter() {
  return (
    <footer className="mt-16 border-t bg-background">
      <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 px-4 py-6 text-xs text-muted-foreground sm:flex-row sm:px-6">
        <div className="flex items-center gap-2">
          <img src="/logo-light.webp" alt="" className="h-5 w-auto object-contain dark:hidden" />
          <img src="/logo.webp" alt="" className="hidden h-5 w-auto object-contain dark:inline-block" />
        </div>
        <nav className="flex flex-wrap items-center gap-4">
          <a href={SUPPORT_URL} className="hover:text-foreground">Support</a>
          <a href={PRIVACY_URL} className="hover:text-foreground">Privacy</a>
          <a href={TERMS_URL} className="hover:text-foreground">Terms</a>
          <a href={STATUS_URL} className="hover:text-foreground">Status</a>
          <a href={DASHBOARD_URL} className="hover:text-foreground">Back to OdontoX</a>
        </nav>
      </div>
    </footer>
  );
}
  • Step 2: Mount in shell
In marketplace-app/src/components/MarketplaceShell.tsx, just after <main>...</main>:
<MarketplaceFooter />
And import it. Also wrap the page in a flex column so the footer hugs the bottom on short pages:
<div className="flex min-h-screen flex-col bg-gradient-to-b from-background to-muted/20">
  ... header ...
  <main className="mx-auto w-full max-w-6xl flex-1">
    <Outlet />
  </main>
  <MarketplaceFooter />
</div>
  • Step 3: Manual smoke
Open /, /apps/ai-rephraser, and a 404 — confirm footer renders on each.
  • Step 4: Commit
git add marketplace-app/src/components/MarketplaceFooter.tsx marketplace-app/src/components/MarketplaceShell.tsx
git commit -m "feat(marketplace): minimal global footer (logo + 5 links)"

Files:
  • Modify: marketplace-app/src/components/MarketplaceShell.tsx
  • Step 1: Add NavLinks to the header
In MarketplaceShell.tsx, after the logo lockup and before the right-side controls, add:
import { NavLink } from 'react-router-dom';
// ...
<nav className="ml-6 hidden items-center gap-1 sm:flex">
  <NavLink
    to="/"
    end
    className={({ isActive }) =>
      `rounded px-3 py-1.5 text-sm transition ${
        isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'
      }`
    }
  >
    Home
  </NavLink>
  <NavLink
    to="/my-apps"
    className={({ isActive }) =>
      `rounded px-3 py-1.5 text-sm transition ${
        isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'
      }`
    }
  >
    My Apps
  </NavLink>
</nav>
  • Step 2: Commit
git add marketplace-app/src/components/MarketplaceShell.tsx
git commit -m "feat(marketplace): add Home / My Apps nav links to shell"

Task 8: Hero banner

Files:
  • Create: marketplace-app/src/components/marketplace/MarketplaceHero.tsx
  • Add asset: marketplace-app/public/hero-mockup.webp (use any OdontoX module screenshot — appointments calendar is a good default; placeholder is fine for v1)
  • Step 1: Implement the hero component
import { ArrowRight } from 'lucide-react';

export function MarketplaceHero({ onCtaClick }: { onCtaClick?: () => void }) {
  return (
    <section className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-primary/10 via-accent/30 to-primary/5 p-8 sm:p-12">
      <div className="relative z-10 grid gap-8 lg:grid-cols-2 lg:items-center">
        <div className="space-y-4">
          <h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl">
            Extend your practice with apps built for OdontoX
          </h1>
          <p className="max-w-xl text-base text-muted-foreground">
            Add modules, expand storage, invite portal users. Subscribe and our team takes it from there.
          </p>
          <button
            type="button"
            onClick={onCtaClick}
            className="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition hover:opacity-90"
          >
            Browse the marketplace
            <ArrowRight className="size-4" />
          </button>
        </div>
        <div className="relative hidden lg:block">
          <img
            src="/hero-mockup.webp"
            alt=""
            loading="lazy"
            className="w-full select-none rounded-xl shadow-xl"
          />
        </div>
      </div>
    </section>
  );
}
  • Step 2: Add a placeholder image
If a screenshot isn’t available immediately, drop any 800×600 WebP into marketplace-app/public/hero-mockup.webp. The page must not crash if it 404s — <img> will render the broken icon, which is acceptable for v1 but should be replaced before deploy.
  • Step 3: Commit
git add marketplace-app/src/components/marketplace/MarketplaceHero.tsx marketplace-app/public/hero-mockup.webp
git commit -m "feat(marketplace): hero banner (indigo gradient + product mockup)"

Files:
  • Create: marketplace-app/src/components/marketplace/RecommendedForYouRow.tsx
  • Create: marketplace-app/src/hooks/use-clinic-modules.ts
  • Step 1: Hook to read enabled modules for the active clinic
Check whether marketplace-app already exposes the clinic’s enabled modules. If not, add use-clinic-modules.ts:
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { getActiveClinicId } from './use-my-clinics';

export function useClinicEnabledModules() {
  const clinicId = getActiveClinicId();
  return useQuery<string[]>({
    queryKey: ['marketplace', clinicId ?? 'no', 'enabled-modules'],
    queryFn: async () => {
      if (!clinicId) return [];
      const res = await api.get<{ modules: Array<{ moduleKey: string; isEnabled: boolean }> }>(
        `/clinics/${clinicId}/modules`,
      );
      return (res?.modules ?? []).filter((m) => m.isEnabled).map((m) => m.moduleKey);
    },
    staleTime: 60_000,
    enabled: !!clinicId,
  });
}
(If a different endpoint is used to fetch enabled modules in ui/, mirror that.)
  • Step 2: Implement the carousel
import { useRef } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { ListingCard } from './ListingCard';
import { useMarketplaceListings } from '@/hooks/use-marketplace-listings';
import { useClinicEnabledModules } from '@/hooks/use-clinic-modules';

export function RecommendedForYouRow() {
  const { data: listings = [] } = useMarketplaceListings();
  const { data: enabled = [] } = useClinicEnabledModules();
  const scroller = useRef<HTMLDivElement>(null);

  const recommended = listings
    .filter((l) => l.subscriptionState !== 'active')
    .map((l) => ({
      l,
      score: (l.relatedModules ?? []).filter((m) => enabled.includes(m)).length,
    }))
    .sort((a, b) => b.score - a.score || a.l.sortOrder - b.l.sortOrder)
    .slice(0, 4)
    .map((x) => x.l);

  if (recommended.length === 0) return null;

  return (
    <section className="rounded-xl border bg-card p-5">
      <div className="mb-4 flex items-center justify-between">
        <h2 className="text-lg font-semibold">Recommended for you</h2>
        <div className="flex gap-1">
          <button
            type="button"
            onClick={() => scroller.current?.scrollBy({ left: -320, behavior: 'smooth' })}
            className="rounded-full border p-1.5 hover:bg-accent"
            aria-label="Previous"
          >
            <ChevronLeft className="size-4" />
          </button>
          <button
            type="button"
            onClick={() => scroller.current?.scrollBy({ left: 320, behavior: 'smooth' })}
            className="rounded-full border p-1.5 hover:bg-accent"
            aria-label="Next"
          >
            <ChevronRight className="size-4" />
          </button>
        </div>
      </div>
      <div
        ref={scroller}
        className="flex gap-4 overflow-x-auto scroll-smooth pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
      >
        {recommended.map((l) => (
          <div key={l.id} className="w-72 shrink-0">
            <ListingCard listing={l} />
          </div>
        ))}
      </div>
    </section>
  );
}
  • Step 3: Commit
git add marketplace-app/src/components/marketplace/RecommendedForYouRow.tsx marketplace-app/src/hooks/use-clinic-modules.ts
git commit -m "feat(marketplace): recommended-for-you carousel ranked by enabled modules"

Files:
  • Create: marketplace-app/src/components/marketplace/MarketplaceSidebar.tsx
  • Step 1: Implement sidebar
import { AVAILABLE_MODULES } from '@/lib/modules';
import { useClinicEnabledModules } from '@/hooks/use-clinic-modules';
import type { MarketplaceCategory, MarketplaceListing } from '@/hooks/use-marketplace-listings';

const CATEGORIES: Array<{ id: MarketplaceCategory; label: string }> = [
  { id: 'clinical', label: 'Clinical' },
  { id: 'admin',    label: 'Admin' },
  { id: 'comms',    label: 'Comms' },
  { id: 'infra',    label: 'Infrastructure' },
];

export type SidebarFilter =
  | { kind: 'none' }
  | { kind: 'module'; moduleKey: string }
  | { kind: 'category'; category: MarketplaceCategory };

export function MarketplaceSidebar({
  listings,
  filter,
  onChange,
}: {
  listings: MarketplaceListing[];
  filter: SidebarFilter;
  onChange: (f: SidebarFilter) => void;
}) {
  const { data: enabled = [] } = useClinicEnabledModules();
  const enabledModules = AVAILABLE_MODULES.filter((m) => enabled.includes(m.key));
  const moduleCounts = new Map<string, number>();
  for (const l of listings) {
    for (const m of l.relatedModules ?? []) {
      moduleCounts.set(m, (moduleCounts.get(m) ?? 0) + 1);
    }
  }
  const categoryCounts = new Map<MarketplaceCategory, number>();
  for (const l of listings) {
    categoryCounts.set(l.category, (categoryCounts.get(l.category) ?? 0) + 1);
  }

  const isActive = (test: SidebarFilter) =>
    filter.kind === test.kind &&
    ('moduleKey' in filter && 'moduleKey' in test ? filter.moduleKey === test.moduleKey : true) &&
    ('category' in filter && 'category' in test ? filter.category === test.category : true);

  return (
    <aside className="space-y-6 lg:sticky lg:top-20">
      {filter.kind !== 'none' && (
        <button
          type="button"
          onClick={() => onChange({ kind: 'none' })}
          className="text-xs text-primary hover:underline"
        >
          Clear filter
        </button>
      )}
      {enabledModules.length > 0 && (
        <section>
          <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
            Recommended for
          </h3>
          <ul className="space-y-0.5">
            {enabledModules.map((m) => {
              const target: SidebarFilter = { kind: 'module', moduleKey: m.key };
              const active = isActive(target);
              const count = moduleCounts.get(m.key) ?? 0;
              return (
                <li key={m.key}>
                  <button
                    type="button"
                    onClick={() => onChange(active ? { kind: 'none' } : target)}
                    className={`w-full rounded px-2 py-1.5 text-left text-sm transition ${
                      active ? 'bg-accent font-medium text-accent-foreground' : 'hover:bg-muted'
                    }`}
                  >
                    {m.label}{' '}
                    <span className="text-xs text-muted-foreground">({count})</span>
                  </button>
                </li>
              );
            })}
          </ul>
        </section>
      )}
      <section>
        <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
          Categories
        </h3>
        <ul className="space-y-0.5">
          {CATEGORIES.map((c) => {
            const target: SidebarFilter = { kind: 'category', category: c.id };
            const active = isActive(target);
            const count = categoryCounts.get(c.id) ?? 0;
            return (
              <li key={c.id}>
                <button
                  type="button"
                  onClick={() => onChange(active ? { kind: 'none' } : target)}
                  className={`w-full rounded px-2 py-1.5 text-left text-sm transition ${
                    active ? 'bg-accent font-medium text-accent-foreground' : 'hover:bg-muted'
                  }`}
                >
                  {c.label}{' '}
                  <span className="text-xs text-muted-foreground">({count})</span>
                </button>
              </li>
            );
          })}
        </ul>
      </section>
    </aside>
  );
}
  • Step 2: Create the shared modules lib for marketplace-app
If marketplace-app/src/lib/modules.ts doesn’t exist, create it duplicating just the key and label fields from server/src/constants/modules.ts (a thin client-side mirror is acceptable since the list is small and rarely changes).
  • Step 3: Commit
git add marketplace-app/src/components/marketplace/MarketplaceSidebar.tsx marketplace-app/src/lib/modules.ts
git commit -m "feat(marketplace): faceted sidebar — modules + categories"

Task 11: Sectioned grid + flat-when-filtered main column

Files:
  • Create: marketplace-app/src/components/marketplace/SectionedListings.tsx
  • Step 1: Implement
import { Link } from 'react-router-dom';
import { ListingCard } from './ListingCard';
import type { MarketplaceCategory, MarketplaceListing } from '@/hooks/use-marketplace-listings';
import type { SidebarFilter } from './MarketplaceSidebar';

const CATEGORY_LABEL: Record<MarketplaceCategory, string> = {
  clinical: 'Clinical',
  admin: 'Admin',
  comms: 'Comms',
  infra: 'Infrastructure',
};
const CATEGORY_ORDER: MarketplaceCategory[] = ['clinical', 'admin', 'comms', 'infra'];

export function SectionedListings({
  listings,
  filter,
  onSelectCategory,
}: {
  listings: MarketplaceListing[];
  filter: SidebarFilter;
  onSelectCategory: (c: MarketplaceCategory) => void;
}) {
  if (filter.kind === 'none') {
    const byCategory = new Map<MarketplaceCategory, MarketplaceListing[]>();
    for (const l of listings) {
      const arr = byCategory.get(l.category) ?? [];
      arr.push(l);
      byCategory.set(l.category, arr);
    }
    return (
      <div className="space-y-10">
        {CATEGORY_ORDER.map((c) => {
          const rows = byCategory.get(c) ?? [];
          if (rows.length === 0) return null;
          return (
            <section key={c}>
              <header className="mb-4 flex items-center justify-between">
                <h2 className="text-lg font-semibold">
                  {CATEGORY_LABEL[c]} <span className="text-muted-foreground">({rows.length})</span>
                </h2>
                {rows.length > 3 && (
                  <button
                    type="button"
                    onClick={() => onSelectCategory(c)}
                    className="text-sm text-primary hover:underline"
                  >
                    See all
                  </button>
                )}
              </header>
              <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
                {rows.slice(0, 3).map((l) => (
                  <ListingCard key={l.id} listing={l} />
                ))}
              </div>
            </section>
          );
        })}
      </div>
    );
  }

  const filtered = listings.filter((l) => {
    if (filter.kind === 'category') return l.category === filter.category;
    if (filter.kind === 'module') return (l.relatedModules ?? []).includes(filter.moduleKey);
    return true;
  });

  if (filtered.length === 0) {
    return (
      <div className="rounded-xl border bg-muted/30 p-12 text-center text-muted-foreground">
        No apps match this filter.
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
      {filtered.map((l) => (
        <ListingCard key={l.id} listing={l} />
      ))}
    </div>
  );
}
  • Step 2: Commit
git add marketplace-app/src/components/marketplace/SectionedListings.tsx
git commit -m "feat(marketplace): sectioned grid on default view, flat grid when filtered"

Task 12: Compose the new Home page

Files:
  • Modify: marketplace-app/src/pages/MarketplaceIndex.tsx
  • Step 1: Rewrite the page
Replace the body of MarketplaceIndex.tsx with:
import { useMemo, useRef, useState } from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { MarketplaceHero } from '@/components/marketplace/MarketplaceHero';
import { RecommendedForYouRow } from '@/components/marketplace/RecommendedForYouRow';
import {
  MarketplaceSidebar,
  type SidebarFilter,
} from '@/components/marketplace/MarketplaceSidebar';
import { SectionedListings } from '@/components/marketplace/SectionedListings';
import { useMarketplaceListings } from '@/hooks/use-marketplace-listings';

export default function MarketplaceIndex() {
  const { data: listings = [], isLoading } = useMarketplaceListings();
  const [q, setQ] = useState('');
  const [filter, setFilter] = useState<SidebarFilter>({ kind: 'none' });
  const gridAnchor = useRef<HTMLDivElement>(null);

  const filteredBySearch = useMemo(() => {
    const query = q.trim().toLowerCase();
    if (!query) return listings;
    return listings.filter((l) =>
      `${l.displayName} ${l.tagline}`.toLowerCase().includes(query),
    );
  }, [listings, q]);

  return (
    <div className="space-y-8 p-6 lg:p-10">
      <MarketplaceHero
        onCtaClick={() => gridAnchor.current?.scrollIntoView({ behavior: 'smooth' })}
      />

      <RecommendedForYouRow />

      <div ref={gridAnchor} className="relative max-w-md">
        <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
        <Input
          placeholder="Search apps"
          className="pl-9"
          value={q}
          onChange={(e) => setQ(e.target.value)}
        />
      </div>

      {isLoading ? (
        <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {Array.from({ length: 6 }).map((_, i) => (
            <div key={i} className="h-44 animate-pulse rounded-xl bg-muted" />
          ))}
        </div>
      ) : (
        <div className="grid gap-8 lg:grid-cols-[240px_1fr]">
          <MarketplaceSidebar
            listings={filteredBySearch}
            filter={filter}
            onChange={setFilter}
          />
          <SectionedListings
            listings={filteredBySearch}
            filter={filter}
            onSelectCategory={(c) => setFilter({ kind: 'category', category: c })}
          />
        </div>
      )}
    </div>
  );
}
  • Step 2: Manual smoke
Open /. Verify:
  • Hero renders.
  • Recommended-for-you row renders (or is hidden if none match).
  • Sidebar with Recommended for + Categories renders on lg+.
  • Default view shows sectioned rows by category.
  • Clicking a sidebar item collapses to a flat grid.
  • “Clear filter” returns to sectioned rows.
  • Step 3: Commit
git add marketplace-app/src/pages/MarketplaceIndex.tsx
git commit -m "feat(marketplace): new home — hero + carousel + facets + sectioned/flat grid"

Task 13: Restructure App Detail page with breadcrumbs and right rail

Files:
  • Create: marketplace-app/src/components/marketplace/DetailRail.tsx
  • Modify: marketplace-app/src/pages/MarketplaceDetail.tsx
  • Step 1: Implement DetailRail
import { Link } from 'react-router-dom';
import { AVAILABLE_MODULES } from '@/lib/modules';

export function DetailRail({
  versionLabel,
  lastUpdatedAt,
  category,
  relatedModules,
  dependencies,
  releasesHref,
}: {
  versionLabel: string;
  lastUpdatedAt: string;
  category: string;
  relatedModules: string[];
  dependencies: string[];
  releasesHref: string;
}) {
  const moduleLabels = relatedModules
    .map((k) => AVAILABLE_MODULES.find((m) => m.key === k)?.label ?? k)
    .filter(Boolean);
  const formatted = new Date(lastUpdatedAt).toLocaleDateString(undefined, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  });

  return (
    <aside className="space-y-5 rounded-xl border bg-card p-5 text-sm lg:sticky lg:top-20">
      <Row label="Version" value={versionLabel} />
      <Row label="Last updated" value={formatted} />
      <ChipRow label="Categories" items={[capitalize(category)]} />
      {moduleLabels.length > 0 && <ChipRow label="Recommended for" items={moduleLabels} />}
      {dependencies.length > 0 && <ChipRow label="Dependencies" items={dependencies} />}
      <div className="border-t pt-3">
        <Link to={releasesHref} className="text-sm text-primary hover:underline">
          See recent releases →
        </Link>
      </div>
    </aside>
  );
}

function Row({ label, value }: { label: string; value: string }) {
  return (
    <div className="space-y-0.5">
      <div className="text-xs font-medium text-muted-foreground">{label}</div>
      <div className="text-sm">{value}</div>
    </div>
  );
}

function ChipRow({ label, items }: { label: string; items: string[] }) {
  return (
    <div className="space-y-1">
      <div className="text-xs font-medium text-muted-foreground">{label}</div>
      <div className="flex flex-wrap gap-1">
        {items.map((i) => (
          <span key={i} className="rounded-full bg-muted px-2 py-0.5 text-xs">
            {i}
          </span>
        ))}
      </div>
    </div>
  );
}

function capitalize(s: string) {
  return s ? s[0].toUpperCase() + s.slice(1) : s;
}
  • Step 2: Rewrite MarketplaceDetail layout
Open marketplace-app/src/pages/MarketplaceDetail.tsx. Wrap the existing content in the new structure:
import { Link, useParams } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { ListingHero } from '@/components/marketplace/ListingHero';
import { DetailRail } from '@/components/marketplace/DetailRail';
import { useMarketplaceListing } from '@/hooks/use-marketplace-listings';
// ... existing imports for screenshots / FAQ rendering

export default function MarketplaceDetail() {
  const { key = '' } = useParams();
  const { data, isLoading } = useMarketplaceListing(key);

  if (isLoading || !data) return <div className="p-10">Loading…</div>;
  const { listing, subscriptionState } = data;

  return (
    <div className="space-y-6 p-6 lg:p-10">
      <nav className="flex items-center gap-1 text-xs text-muted-foreground">
        <Link to="/" className="hover:text-foreground">Home</Link>
        <ChevronRight className="size-3" />
        <Link to="/" className="hover:text-foreground">All Apps</Link>
        <ChevronRight className="size-3" />
        <span className="text-foreground">{listing.displayName}</span>
      </nav>

      <ListingHero listing={listing} subscriptionState={subscriptionState} />

      <div className="grid gap-8 lg:grid-cols-[1fr_280px]">
        <main className="space-y-8">
          <section>
            <h2 className="mb-2 text-xl font-semibold">Overview</h2>
            {/* render listing.longDescriptionMd, listing.whatYouGet, screenshots, FAQ in their existing components */}
          </section>
        </main>
        <DetailRail
          versionLabel={listing.versionLabel}
          lastUpdatedAt={listing.lastUpdatedAt}
          category={listing.category}
          relatedModules={listing.relatedModules ?? []}
          dependencies={listing.dependencies ?? []}
          releasesHref={`/apps/${listing.id}/releases`}
        />
      </div>
    </div>
  );
}
Re-use existing sub-components (markdown renderer, screenshot gallery, FAQ accordion) — only the layout shell changes. Find them in the current file and pull them into the new structure.
  • Step 3: Manual smoke
Open /apps/ai-rephraser. Verify:
  • Breadcrumbs render and link back to Home.
  • Hero + Open/Subscribe CTA still works.
  • Right rail shows Version / Last updated / Categories / Recommended for / Dependencies.
  • “See recent releases →” link points to /apps/ai-rephraser/releases.
  • On narrow widths the rail stacks below content.
  • Step 4: Commit
git add marketplace-app/src/components/marketplace/DetailRail.tsx marketplace-app/src/pages/MarketplaceDetail.tsx
git commit -m "feat(marketplace): app detail page with breadcrumbs + right rail"

Task 14: Releases sub-route (modal or full page)

Files:
  • Create: marketplace-app/src/pages/MarketplaceReleases.tsx
  • Modify: marketplace-app/src/App.tsx (add route)
  • Step 1: Implement page
import { Link, useParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import { useMarketplaceListing } from '@/hooks/use-marketplace-listings';

export default function MarketplaceReleases() {
  const { key = '' } = useParams();
  const { data, isLoading } = useMarketplaceListing(key);
  if (isLoading || !data) return <div className="p-10">Loading…</div>;
  const { listing, releases } = data;

  return (
    <div className="space-y-6 p-6 lg:p-10">
      <Link to={`/apps/${listing.id}`} className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
        <ChevronLeft className="size-4" /> Back to {listing.displayName}
      </Link>
      <h1 className="text-2xl font-semibold">Releases — {listing.displayName}</h1>
      <ol className="space-y-6">
        {releases.map((r) => (
          <li key={r.id} className="border-b pb-6 last:border-b-0">
            <div className="flex items-center gap-2">
              <span className="font-mono text-sm">{r.versionLabel}</span>
              {r.isMajor && <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">Major</span>}
              <span className="text-xs text-muted-foreground">
                {new Date(r.releasedAt).toLocaleDateString()}
              </span>
            </div>
            <p className="mt-1 text-sm">{r.summary}</p>
          </li>
        ))}
      </ol>
    </div>
  );
}
  • Step 2: Wire route
In marketplace-app/src/App.tsx, add a route under the MarketplaceShell layout:
<Route path="apps/:key/releases" element={<MarketplaceReleases />} />
  • Step 3: Commit
git add marketplace-app/src/pages/MarketplaceReleases.tsx marketplace-app/src/App.tsx
git commit -m "feat(marketplace): dedicated releases page per listing"

Task 15: My Apps page (Subscribed + Requests tabs)

Files:
  • Create: marketplace-app/src/pages/MyApps.tsx
  • Modify: marketplace-app/src/App.tsx (add route, redirect /requests)
  • Step 1: Implement
import { useState } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import { ListingCard } from '@/components/marketplace/ListingCard';
import { useMarketplaceListings } from '@/hooks/use-marketplace-listings';
import MarketplaceRequests from './MarketplaceRequests';

type Tab = 'subscribed' | 'requests';

export default function MyApps() {
  const [params, setParams] = useSearchParams();
  const initial = (params.get('tab') as Tab) ?? 'subscribed';
  const [tab, setTab] = useState<Tab>(initial);
  const { data: listings = [] } = useMarketplaceListings();
  const subscribed = listings.filter((l) => l.subscriptionState === 'active');

  const setActiveTab = (t: Tab) => {
    setTab(t);
    setParams({ tab: t });
  };

  return (
    <div className="space-y-6 p-6 lg:p-10">
      <header>
        <h1 className="text-3xl font-semibold tracking-tight">My Apps</h1>
        <p className="text-sm text-muted-foreground">Manage your subscriptions and recent requests.</p>
      </header>

      <div className="border-b">
        <nav className="flex gap-1">
          {(['subscribed', 'requests'] as const).map((t) => (
            <button
              key={t}
              type="button"
              onClick={() => setActiveTab(t)}
              className={`-mb-px border-b-2 px-4 py-2 text-sm transition ${
                tab === t
                  ? 'border-primary text-foreground'
                  : 'border-transparent text-muted-foreground hover:text-foreground'
              }`}
            >
              {t === 'subscribed' ? 'Subscribed' : 'Requests'}
            </button>
          ))}
        </nav>
      </div>

      {tab === 'subscribed' && (
        subscribed.length === 0 ? (
          <div className="rounded-xl border bg-muted/30 p-12 text-center text-muted-foreground">
            You haven't subscribed to anything yet.{' '}
            <Link to="/" className="text-primary hover:underline">Browse the marketplace →</Link>
          </div>
        ) : (
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {subscribed.map((l) => <ListingCard key={l.id} listing={l} />)}
          </div>
        )
      )}

      {tab === 'requests' && <MarketplaceRequests />}
    </div>
  );
}
  • Step 2: Routes
In marketplace-app/src/App.tsx:
  • Add <Route path="my-apps" element={<MyApps />} /> under the MarketplaceShell layout.
  • Replace any existing top-level <Route path="requests" ...> with <Route path="requests" element={<Navigate to="/my-apps?tab=requests" replace />} /> (import Navigate from react-router-dom).
  • Step 3: Manual smoke
  • Visit /my-apps → defaults to Subscribed tab.
  • Visit /my-apps?tab=requests → opens Requests tab.
  • Visit /requests → redirects to /my-apps?tab=requests.
  • Click between tabs and refresh — URL stays in sync.
  • Step 4: Commit
git add marketplace-app/src/pages/MyApps.tsx marketplace-app/src/App.tsx
git commit -m "feat(marketplace): My Apps page with Subscribed and Requests tabs"

Task 16: go.odontox.io — Marketplace nav item (admin/owner only)

Files:
  • Modify: ui/src/hooks/useNavItems.ts
  • Step 1: Find the tenant nav blocks
Read ui/src/hooks/useNavItems.ts around lines 1-200. Identify the 'admin' (and 'owner' if separate) role blocks.
  • Step 2: Add the Marketplace item
In each admin/owner role block, append a new top-level item:
{ id: 'marketplace', label: 'Marketplace', icon: ic(LayoutGrid) },
Do NOT add it to doctor, receptionist, nurse, lab_tech blocks.
  • Step 3: Confirm LayoutGrid is imported
Check the lucide-react import block at the top. If not present, add LayoutGrid to the imports.
  • Step 4: Commit
git add ui/src/hooks/useNavItems.ts
git commit -m "feat(marketplace): Marketplace sidebar item for admin/owner roles"

Task 17: MarketplaceFlyout component

Files:
  • Create: ui/src/components/MarketplaceFlyout.tsx
  • Step 1: Implement
import { useEffect, useRef } from 'react';
import { ExternalLink, X } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';

interface InstalledListing {
  id: string;
  displayName: string;
  iconKind: 'lucide' | 'upload';
  iconValue: string;
  heroColor: string;
  subscriptionState: string;
}

function useInstalledApps() {
  return useQuery<InstalledListing[]>({
    queryKey: ['marketplace-installed-apps'],
    queryFn: async () => {
      const res = await api.get<{ listings: InstalledListing[] }>('/marketplace/listings');
      return (res?.listings ?? []).filter((l) => l.subscriptionState === 'active');
    },
    staleTime: 60_000,
  });
}

const MARKETPLACE_URL = 'https://market.odontox.io';

export function MarketplaceFlyout({ open, onClose }: { open: boolean; onClose: () => void }) {
  const { data = [], isLoading, error } = useInstalledApps();
  const panelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };
    const onClick = (e: MouseEvent) => {
      if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose();
    };
    document.addEventListener('keydown', onKey);
    // Defer so the click that opened the flyout doesn't immediately close it
    const t = setTimeout(() => document.addEventListener('mousedown', onClick), 0);
    return () => {
      document.removeEventListener('keydown', onKey);
      clearTimeout(t);
      document.removeEventListener('mousedown', onClick);
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div
      ref={panelRef}
      role="dialog"
      aria-label="Installed apps"
      className="fixed inset-y-0 left-[var(--sidebar-width,16rem)] z-40 w-[420px] border-r border-t border-b bg-popover text-popover-foreground shadow-xl"
    >
      <header className="flex items-center justify-between border-b px-4 py-3">
        <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
          Installed apps
        </h3>
        <div className="flex items-center gap-2">
          <a
            href={MARKETPLACE_URL}
            target="_blank"
            rel="noopener"
            className="inline-flex items-center gap-1 rounded-md border border-primary px-3 py-1.5 text-xs font-medium text-primary hover:bg-primary/10"
          >
            Explore more <ExternalLink className="size-3.5" />
          </a>
          <button type="button" onClick={onClose} aria-label="Close" className="rounded p-1 hover:bg-accent">
            <X className="size-4" />
          </button>
        </div>
      </header>
      <div className="max-h-[calc(100vh-3.5rem)] overflow-y-auto p-4">
        {isLoading ? (
          <div className="grid grid-cols-3 gap-3">
            {Array.from({ length: 6 }).map((_, i) => (
              <div key={i} className="h-20 animate-pulse rounded bg-muted" />
            ))}
          </div>
        ) : error ? (
          <p className="text-sm text-muted-foreground">
            Couldn't load installed apps.{' '}
            <a href={MARKETPLACE_URL} target="_blank" rel="noopener" className="text-primary hover:underline">
              Open marketplace →
            </a>
          </p>
        ) : data.length === 0 ? (
          <p className="text-sm text-muted-foreground">
            No apps installed yet.{' '}
            <a href={MARKETPLACE_URL} target="_blank" rel="noopener" className="text-primary hover:underline">
              Browse the marketplace →
            </a>
          </p>
        ) : (
          <div className="grid grid-cols-3 gap-3">
            {data.map((l) => (
              <div key={l.id} className="flex flex-col items-center text-center">
                <div className="grid size-14 place-items-center rounded-xl border bg-card text-xs font-medium">
                  {l.iconKind === 'lucide' ? l.iconValue.slice(0, 2) : '🧩'}
                </div>
                <span className="mt-1 line-clamp-1 text-xs">{l.displayName}</span>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}
(If a richer icon renderer exists in ui/ already — e.g., a lucide-name-to-component helper — substitute it for the placeholder.)
  • Step 2: Commit
git add ui/src/components/MarketplaceFlyout.tsx
git commit -m "feat(marketplace): installed-apps flyout component"

Task 18: Wire flyout into sidebar

Files:
  • Modify: ui/src/components/appSidebar.tsx
  • Step 1: Read the existing nav-click handler in appSidebar.tsx
Identify how a nav item handles click (it likely navigates to a route via setActivePage or similar). We need to intercept the 'marketplace' id to open the flyout instead of navigating.
  • Step 2: Add flyout state and intercept
Near the top of the component, add:
import { useState } from 'react';
import { MarketplaceFlyout } from './MarketplaceFlyout';
// ...
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
Find where each nav item is rendered. Wrap (or modify) the click handler so when item.id === 'marketplace':
onClick={() => {
  if (item.id === 'marketplace') {
    setMarketplaceOpen((v) => !v);
    return;
  }
  // existing handler
}}
At the bottom of the component’s JSX (after the sidebar nav), render:
<MarketplaceFlyout open={marketplaceOpen} onClose={() => setMarketplaceOpen(false)} />
  • Step 3: Close flyout on route change
If the existing sidebar exposes the active route, add an effect:
useEffect(() => {
  setMarketplaceOpen(false);
}, [activePage /* or the equivalent route signal */]);
  • Step 4: Manual smoke
Run: pnpm --filter ui dev. Sign in as an admin/owner against the local dev server. Confirm:
  • “Marketplace” nav item appears.
  • Clicking it opens the flyout to the right of the sidebar.
  • Esc / outside click / clicking another nav item closes it.
  • “Explore more ↗” opens https://market.odontox.io in a new tab.
Sign in as a doctor — confirm the item is hidden.
  • Step 5: Commit
git add ui/src/components/appSidebar.tsx
git commit -m "feat(marketplace): wire installed-apps flyout to sidebar item"

Task 19: Bump APP_VERSION

Files:
  • Modify: ui/src/pages/sign-in.tsx
  • Step 1: Find and bump APP_VERSION
Grep: grep -n "APP_VERSION" ui/src/pages/sign-in.tsx Increment the patch component (or minor if appropriate to the release plan).
  • Step 2: Commit
git add ui/src/pages/sign-in.tsx
git commit -m "chore(release): bump APP_VERSION for marketplace redesign"

Task 20: End-to-end smoke + Playwright check

Files:
  • Create: ui/e2e/marketplace-flyout.spec.ts
  • Step 1: Write the spec
import { test, expect } from '@playwright/test';
import { signIn } from './helpers/auth';

test.describe('Marketplace flyout', () => {
  test('admin sees Marketplace nav and can open flyout', async ({ page }) => {
    await signIn(page, { role: 'admin' });
    const marketplaceNav = page.getByRole('button', { name: 'Marketplace' });
    await expect(marketplaceNav).toBeVisible();
    await marketplaceNav.click();
    await expect(page.getByRole('dialog', { name: 'Installed apps' })).toBeVisible();
    const exploreLink = page.getByRole('link', { name: /Explore more/ });
    await expect(exploreLink).toHaveAttribute('href', /market\.odontox\.io/);
    await page.keyboard.press('Escape');
    await expect(page.getByRole('dialog', { name: 'Installed apps' })).not.toBeVisible();
  });

  test('doctor does not see Marketplace nav', async ({ page }) => {
    await signIn(page, { role: 'doctor' });
    await expect(page.getByRole('button', { name: 'Marketplace' })).toHaveCount(0);
  });
});
Adjust signIn helper invocation to match the existing pattern in ui/e2e/.
  • Step 2: Run
Run: pnpm --filter ui exec playwright test marketplace-flyout Expected: PASS.
  • Step 3: Commit
git add ui/e2e/marketplace-flyout.spec.ts
git commit -m "test(marketplace): e2e for flyout visibility per role"

Self-Review

Spec coverage check (cross-walked spec sections to tasks):
  • Sidebar flyout & nav item → Tasks 16, 17, 18, 20
  • Marketplace shell (theming, nav links, footer) → Tasks 5, 6, 7
  • Home page (hero, carousel, sidebar, sectioned/flat grid) → Tasks 8, 9, 10, 11, 12
  • App detail page (breadcrumbs, right rail, releases sub-route) → Tasks 13, 14
  • My Apps page → Task 15
  • Data model additions → Task 1
  • Server response surfaces new fields → Task 2
  • Superadmin editor (image upload, modules, dependencies) → Tasks 3, 4
  • API docs update → Task 3
  • APP_VERSION bump → Task 19
No spec section is missing a task. Placeholder scan: no TBD/TODO in concrete tasks. The hero mockup image is intentionally a v1 placeholder — flagged in Task 8 Step 2 with explicit instructions to replace before deploy. Acceptable per spec’s “use gradient + product mockup” v1 scope. Type consistency: SidebarFilter defined in Task 10, used in Tasks 11 and 12 with matching shape. MarketplaceListing.relatedModules / dependencies added in Task 2 are read in Tasks 9, 10, 11, 12, 13 — same names everywhere. Risks worth flagging to whoever executes:
  • Task 9 assumes /clinics/:id/modules exists. If the endpoint shape differs in marketplace-app, mirror whatever the existing app uses to fetch enabled modules.
  • Task 16 assumes admin/owner are distinct roles in useNavItems.ts. If your codebase uses a single 'admin' role for both, add the item only to that block.
  • Task 18’s sidebar wiring depends on the specific click-handler shape in appSidebar.tsx — read the existing pattern before editing.

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-21-marketplace-redesign.md. Two execution options: 1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration. 2. Inline Execution — execute tasks in this session using executing-plans, batch with checkpoints for review. Which approach?