# Multideal editorial-primitive census (component-grain API)

**Purpose:** ground the mod-cms editorial port wave (gap analysis §4 EXTRACT: content/editor, content-react, uploads-react). Component-grain signatures + portability verdicts.
**Donor:** multideal. **SHA pin:** `ff1c8ac32` (`git -C ~/Projects/multideal log -1 --format=%h -- apps/web/src/features`).
**Date:** 2026-06-17. **Method:** read-only Explore (sonnet) over `apps/web/src/{components/ui,features,server,lib}`.

> Freshness: re-check pin before re-using; stale → re-census only the changed path (lazy refresh).

---

## 1. Rich-text editor — PORTABLE (generic presentation primitive)

`apps/web/src/components/ui/primitives/RichTextEditor/RichTextEditor.tsx`; used by `features/admin-page-organizer/RichTextEditDialog.tsx`.

```ts
export interface RichTextEditorProps {
  value: string;                    // current HTML string
  onChange: (html: string) => void; // editor.getHTML() on every update
  dir?: 'ltr' | 'rtl';
  'aria-label'?: string;
}
```

- **Serialization = raw HTML string.** No JSON block doc, no markdown, no ProseMirror node JSON. Stored as `{ he: string; en: string }` (both HTML — localization concern, not baked into the component).
- **Engine:** `@tiptap/react` (individual extension packages, no StarterKit — saves ~25KB gz). Extensions: `Document, Paragraph, Text, History, Bold, Italic, Heading({levels:[2]}), BulletList, ListItem` + `tiptap-text-direction` (RTL). Toolbar: Bold / Italic / H2 / Bullet-list.
- **Verdict:** nearly zero-domain. Only coupling = RTL-first default + `tiptap-text-direction`. Extract as a headless-with-default-toolbar primitive; export with swappable extensions config. **Packaging home = presentation axis, NOT `content/blocks`, NOT `content-react`-proper.**
- **Hazard:** `@tiptap/react` is a React peer dep — declare correctly to avoid bundle duplication when the host also uses Tiptap.
- **Aside:** `KbArticleEditor` is a separate, simpler `<textarea>`+`bodyMd` (markdown) editor — support-domain, not deal content.

## 2. Block model — DOES NOT EXIST

Body is a plain HTML string everywhere. The `LayoutModule<TConfig>` / `LayoutBody` system (`server/page-layout/types.ts`) is a **page-layout slot registry** (`type` = which layout slot to render: `'rich-text-content' | 'deal-row' | 'banner'`), **not** a document block model — no block-type union, no parse/serialize pipeline, no document schema. The `rich-text-content` module's config is just `{ body: { he; en } }`.

**Consequence: `content/blocks` has zero donor → KILL.** Content's existing `body: string` (HTML) already matches.

## 3. Publish / schedule — partial port (LaunchModePicker generic; state machine domain-coupled)

`primitives/LaunchModePicker/LaunchModePicker.tsx`:

```ts
export type LaunchMode = 'now' | 'scheduled';
export interface LaunchModePickerProps {
  value: LaunchMode; onChange: (v: LaunchMode) => void;
  nowLabel: string; scheduledLabel: string; ariaLabel?: string; className?: string;
}
```

- Wraps `SegmentedControl` (radiogroup, roving tabIndex, arrow-key nav). **PORTABLE** as a generic now/scheduled toggle.
- Date field: `windowStart: string` (ISO), zod-validated; constraint "not >5min in past" (client+server); `windowEnd > windowStart`.
- **Deal state machine** `DRAFT → UNDER_REVIEW → PENDING_APPROVAL → ACTIVE|REJECTED`, `ACTIVE → PAUSED|SOLD_OUT|EXPIRED` + approve/reject(reason enum) — **DOMAIN-COUPLED, stays host.**
- **Autosave** `AutosaveState = 'idle'|'saving'|'saved'|'error'` (localStorage + `POST/PATCH /api/vendor/deals/drafts`) — portable pattern, endpoint→injected callback.

## 4. Image / media upload — PORTABLE logic, R2-LOCKED transport

`primitives/ImageUploadField/ImageUploadField.tsx` (+ `useImageUpload.ts`, `lib/image-upload/encodeImage.worker.ts`, `uploadImage.ts`).

```ts
export interface ImageUploadFieldProps {
  label: string; labelTooltip?: string;
  value: string | null;              // display URL
  onChange: (url: string) => void;   // serving URL after upload, '' on remove
  onR2Key?: (r2Key: string) => void; // raw R2 key for DB
  purpose: ImagePurpose;             // vendor_hero|vendor_logo|vendor_gallery|deal_image|avatar|homepage_banner|support_attachment
  aspectRatio?: '1:1' | '16:9';
  focal?: { x: number; y: number }; onFocalChange?: (x: number, y: number) => void;
  className?: string;
}
```

- Flow: select/drop → `getImageDimensions()` → per-`purpose` CONSTRAINTS check → CropModal if needed → `encodeImage()` Web Worker (jsquash WASM → SHA256 + AVIF/WebP) → `POST /api/uploads/complete` (multipart) → server whitelist + conditional-PUT to R2 → `{ sha256, r2Key, url }`.
- Phases: `idle → hashing → decoding → encoding(0–1) → uploading → done|error|cancelled`.
- **Verdict:** `useImageUpload` phases + `encodeImage` worker + focal picker = PORTABLE/provider-agnostic. **Biggest hazard: everything from the multipart POST onward is CF-R2-locked** (`/api/uploads/complete`, `purpose`-keyed dimensions, R2 key structure). A host-agnostic extraction REQUIRES a `StorageAdapter` seam. `ImagePurpose` enum + dimension registry = domain/host-specific → generic `constraints` prop + injected `onUpload`.
- `DealImageManager` (list/reorder/delete by `dealId`) = deal-domain, stays host. **No media-library / browse-existing — upload-only.**

## 5. Form / field primitives

- **Generic, portable:** `FormField` (label+error), `NumberInput`, `Textarea`, `LaunchModePicker`, `ImageUploadField` (modulo purpose/R2).
- **Slug field:** only in `KbArticleEditor`, plain `<Input dir="ltr">`, **no slug-from-title auto-gen.** Generic.
- **Category/Tag pickers** (`FieldsCommon.tsx`): `Select` + `TagPillGroup`; data `CategoryItem = { id; nameHe; nameEn }` — **widgets generic, data shape domain-coupled.**
- **SEO meta fields: NOT PRESENT** anywhere (no og:title/description/canonical fields).
- `DealDurationPicker` — domain semantics; underlying "relative date offset from start" pattern extractable as generic `DateOffsetPicker`.

---

## Porting dispositions (main-loop verdicts, derived from this census)

| Asset | Disposition | Gate |
|---|---|---|
| `content/blocks` | **KILL** | zero donor block model; HTML-string body already matches |
| generic `RichTextEditor` (Tiptap) | **build-eligible** as a presentation-axis primitive (own editor-`react` sibling, Tiptap engine — mirrors ui-primitives adopting Radix) | shares the ui-* family build gate (publish path + ≥2 `-react` siblings); donor-proven surface = shapeable |
| `content-react`-proper (CRUD forms/hooks) | **consumer-gated** | mod-cms = MCP control surface, no editor screen to shape it; de-gates post-v0.0.1 |
| `uploads-react` (`ImageUploadField`) | **seam-gated** | needs `StorageAdapter` seam before host-agnostic extraction |
| `LaunchModePicker` + autosave pattern | **defer** | belongs to a publish/scheduling surface; no near-consumer |
| SEO meta fields | **build (not port)** | absent in donor; net-new when a blueprint needs it |
