diff --git a/.claude/skills/i18n-engineer/SKILL.md b/.claude/skills/i18n-engineer/SKILL.md new file mode 100644 index 0000000..6b1c72d --- /dev/null +++ b/.claude/skills/i18n-engineer/SKILL.md @@ -0,0 +1,167 @@ +--- +name: i18n-engineer +description: Automatically internationalize React components in the minimalblock app for Turkish (tr) and English (en). Detects static text, generates keys, adds translations to both locale files, and replaces hardcoded strings with t() calls. No static text allowed — only i18n. +--- + +# I18n Engineer + +## Use when +- A component or page contains any hardcoded string literal rendered in JSX +- A new page or component is added and needs translating +- A user requests "add i18n", "internationalize this", or "no static text" +- `placeholder`, `aria-label`, `title`, `alt` attributes have hardcoded strings + +## Never leave behind +- String literals inside JSX (e.g. `

Loading...

`) +- Template literals with UI text (e.g. `` `Hello ${name}` ``) +- `placeholder="..."`, `aria-label="..."`, `alt="..."` with literal values +- Button/label/option text hardcoded in JSX + +--- + +## Project i18n stack + +| Item | Value | +|------|-------| +| Library | `react-i18next` + `i18next` | +| Config | `apps/web/src/i18n/index.ts` | +| English | `apps/web/src/i18n/locales/en.ts` | +| Turkish | `apps/web/src/i18n/locales/tr.ts` | +| Hook | `useTranslation()` from `react-i18next` | +| Call | `t('namespace.key')` or `t('namespace.key', { var })` | +| Default lang | Turkish (`tr`) | +| Fallback lang | English (`en`) | + +Both locale files export a **TypeScript `as const` object** — no JSON files, no separate namespace files. + +--- + +## Namespace map + +Pick the namespace based on where the component lives: + +| Namespace | Use for | +|-----------|---------| +| `nav` | Sidebar, top-bar navigation labels | +| `common` | Shared actions: Cancel, Save, Delete, Back, Refresh | +| `auth` | Login, signup, password, email, OAuth strings | +| `profile` | User profile menu items | +| `category` | Product category labels | +| `gallery` | Gallery page, QA queue, toolbar, empty states | +| `upload` | Upload/conversion form and flow | +| `dashboard` | Analytics dashboard, charts, orders widget | +| `orders` | Orders list page, table columns, statuses | +| `product` | Product detail, QA review, modals (embed, reject, trendyol) | + +Add a new top-level namespace only when the content clearly does not fit any existing one. + +--- + +## Key naming rules + +- `camelCase` for all key names +- Nest with objects when a screen section groups ≥ 3 keys (e.g. `deleteModal.title`) +- Use `_one` / `_other` suffixes for plurals (i18next plural convention) +- Use `{{variableName}}` for interpolated values +- Prefix with the namespace in the `t()` call: `t('gallery.loadMore')` + +--- + +## Workflow — step by step + +### 1. Read the target file +Read the full component. Identify every hardcoded string: +- Text nodes: `Foo` → `Foo` +- Attribute strings: `placeholder="Enter name"`, `aria-label="Close"`, `alt="Product image"` +- Conditional text: `error ? 'Failed' : 'Success'` +- Template literal UI text: `` `${count} items` `` + +### 2. Determine namespaces and keys +Map each string to a `namespace.key`. Reuse existing keys from the locale files when they match exactly (check `en.ts` first). + +### 3. Write translations for both locales + +**English (`en.ts`)**: use the original English text (or a clean version of it). + +**Turkish (`tr.ts`)**: translate accurately. Guidelines: +- "Cancel" → "İptal", "Save" → "Kaydet", "Delete" → "Sil", "Back" → "Geri" +- Use formal/polite tone (siz-based where needed) +- Keep brand names (Trendyol, Gemini, GLB) unchanged +- Preserve `{{variable}}` placeholders verbatim + +Edit **both** locale files in the same response — never add a key to one without the other. + +### 4. Replace static text in the component + +Add the import if missing: +```tsx +import { useTranslation } from 'react-i18next'; +``` + +Add the hook inside the component if missing: +```tsx +const { t } = useTranslation(); +``` + +Replace each hardcoded string: +```tsx +// Before + +// After + + +// Before — interpolation +

{count} products synced

+// After +

{t('nav.productsSynced', { count })}

+ +// Before — attribute + +// After + +``` + +### 5. Verify +- No string literals remain in JSX or attributes (except technical values like CSS class names, IDs, event names, URLs) +- Both `en.ts` and `tr.ts` have the new keys at identical paths +- The component still type-checks (`npx nx typecheck web`) +- Run `npx nx dev web` if possible to do a quick visual check + +--- + +## Locale file editing rules + +Both files end with `} as const;`. Insert new keys inside the correct namespace object, maintaining alphabetical order within the group where possible. + +**Example — adding `gallery.filterLabel`:** + +In `en.ts`: +```ts + gallery: { + // ... existing keys ... + filterLabel: 'Filter products', + // ... + }, +``` + +In `tr.ts`: +```ts + gallery: { + // ... existing keys ... + filterLabel: 'Ürünleri filtrele', + // ... + }, +``` + +--- + +## Edge cases + +| Situation | Handling | +|-----------|----------| +| String is purely a CSS class / HTML attribute with no user-visible meaning | Leave as-is | +| String is a URL or route path | Leave as-is | +| String is a log message or `console.error` | Leave as-is | +| String is an enum value / status code passed to logic | Leave as-is; only translate the display label | +| Component receives a string prop from parent | Translate at the call site, not inside the component | +| Dynamic key (e.g. `t(\`category.${slug}\`)`) | Use only when the key set is finite and all variants exist in locales | diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..1bcabd6 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,12 @@ +{ + "name": "api", + "version": "0.0.1", + "private": true, + "dependencies": { + "@minimalblock/ai": "workspace:*", + "@minimalblock/core": "workspace:*", + "@minimalblock/data": "workspace:*", + "@minimalblock/trendyol": "workspace:*", + "@supabase/supabase-js": "^2.105.4" + } +} diff --git a/apps/api/src/lib/import/adapters/adapter-registry.ts b/apps/api/src/lib/import/adapters/adapter-registry.ts new file mode 100644 index 0000000..f808b2b --- /dev/null +++ b/apps/api/src/lib/import/adapters/adapter-registry.ts @@ -0,0 +1,31 @@ +import type { IPageScraperAdapter } from '@minimalblock/core'; +import { MockAdapter } from './mock.adapter.js'; +import { AmazonAdapter } from './amazon.adapter.js'; +import { IkeaAdapter } from './ikea.adapter.js'; +import { GenericHtmlAdapter } from './generic.adapter.js'; + +function normalizeDomain(url: URL): string { + return url.hostname.toLowerCase().replace(/^www\./, ''); +} + +const SUPPORTED_DOMAINS = new Set(['amazon.com', 'etsy.com', 'ikea.com', 'trendyol.com']); + +export class ScraperAdapterRegistry { + private readonly adapters: IPageScraperAdapter[]; + + constructor() { + this.adapters = [ + new MockAdapter(), + new AmazonAdapter(), + new IkeaAdapter(), + ]; + } + + resolve(url: URL): IPageScraperAdapter { + const found = this.adapters.find((adapter) => adapter.canHandle(url)); + if (found) return found; + const domain = normalizeDomain(url); + const level = SUPPORTED_DOMAINS.has(domain) ? 'supported' : 'best_effort'; + return new GenericHtmlAdapter(level); + } +} diff --git a/apps/api/src/lib/import/adapters/amazon.adapter.ts b/apps/api/src/lib/import/adapters/amazon.adapter.ts new file mode 100644 index 0000000..7788a01 --- /dev/null +++ b/apps/api/src/lib/import/adapters/amazon.adapter.ts @@ -0,0 +1,15 @@ +import type { ScrapedPageData } from '@minimalblock/core'; +import { GenericHtmlAdapter } from './generic.adapter.js'; + +export class AmazonAdapter extends GenericHtmlAdapter { + override readonly supportLevel = 'supported' as const; + + override canHandle(url: URL): boolean { + return /amazon\.(com|co\.uk|de|fr|it|es|co\.jp|ca|com\.au)$/.test(url.hostname.toLowerCase().replace(/^www\./, '')); + } + + override async scrape(url: URL): Promise { + const base = await super.scrape(url); + return base; + } +} diff --git a/apps/api/src/lib/import/adapters/generic.adapter.ts b/apps/api/src/lib/import/adapters/generic.adapter.ts new file mode 100644 index 0000000..a14fef0 --- /dev/null +++ b/apps/api/src/lib/import/adapters/generic.adapter.ts @@ -0,0 +1,313 @@ +import type { IPageScraperAdapter, ScrapedImageCandidate, ScrapedPageData, ImportSupportLevel } from '@minimalblock/core'; + +function normalizeDomain(url: URL): string { + return url.hostname.toLowerCase().replace(/^www\./, ''); +} + +function cleanText(value: string | undefined, maxLength = 800): string | undefined { + if (!value) return undefined; + const compact = value + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\b(cookie|accept all|privacy policy|free shipping|subscribe)\b/gi, ' ') + .replace(/\s+\|\s+/g, ' ') + .trim(); + if (!compact) return undefined; + return compact.length > maxLength ? `${compact.slice(0, maxLength - 1).trim()}…` : compact; +} + +function cleanTitle(value: string | undefined): string | undefined { + if (!value) return undefined; + const cleaned = value + .replace(/\s*[|\-]\s*(buy|shop|official|store|online).*/i, '') + .replace(/\s*[|\-]\s*[A-Z0-9 .,&]+$/i, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned || undefined; +} + +function dedupeUrls(urls: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of urls) { + if (!value || seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +function extractMaterials(value: string | undefined): string[] { + if (!value) return []; + const matches = value.match(/\b(leather|wood|metal|glass|ceramic|plastic|cotton|linen|marble|steel|aluminum|fabric|oak|walnut|brass|velvet|bouclé|boucle|rattan|bamboo)\b/gi) ?? []; + return Array.from(new Set(matches.map((item) => item.toLowerCase()))); +} + +function extractDimensions(value: string | undefined): string | undefined { + if (!value) return undefined; + const match = value.match(/(\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m)\s?(?:x|×)\s?\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m)(?:\s?(?:x|×)\s?\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m))?)/i) + ?? value.match(/(\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m)\s?(?:wide|width|tall|height|deep|depth))/i); + return match?.[1]; +} + +function parseJsonLd(html: string): unknown[] { + const matches = Array.from(html.matchAll(/]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)); + return matches.flatMap((match) => { + try { + const parsed = JSON.parse(match[1].trim()) as unknown; + if (Array.isArray(parsed)) return parsed; + return [parsed]; + } catch { + return []; + } + }); +} + +function pickProductJsonLd(entries: unknown[]): Record | null { + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue; + const record = entry as Record; + if (record['@type'] === 'Product') return record; + const graph = record['@graph']; + if (Array.isArray(graph)) { + const found = graph.find((item) => item && typeof item === 'object' && (item as Record)['@type'] === 'Product'); + if (found && typeof found === 'object') return found as Record; + } + } + return null; +} + +function extractMeta(html: string, property: string): string | undefined { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`]+(?:property|name)=["']${escaped}["'][^>]+content=["']([^"']+)["'][^>]*>`, 'i'); + return pattern.exec(html)?.[1]; +} + +function extractTitleTag(html: string): string | undefined { + return html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]; +} + +function extractVisibleImages(html: string, baseUrl: URL): ScrapedImageCandidate[] { + const matches = Array.from(html.matchAll(/]+src=["']([^"']+)["'][^>]*>/gi)); + const urls: ScrapedImageCandidate[] = matches.flatMap((match, index) => { + const rawSrc = match[1]; + const block = match[0]; + try { + const absolute = new URL(rawSrc, baseUrl).toString(); + const lowered = absolute.toLowerCase(); + if (/sprite|icon|logo|badge|payment|rating|star/.test(lowered)) return []; + const alt = block.match(/\salt=["']([^"']*)["']/i)?.[1]; + const title = block.match(/\stitle=["']([^"']*)["']/i)?.[1]; + return [{ sourceUrl: absolute, ordinal: index, confidence: index < 4 ? 'medium' as const : 'low' as const, warnings: [], alt, title }]; + } catch { + return []; + } + }); + + const deduped = dedupeUrls(urls.map((entry) => entry.sourceUrl)); + return deduped.map((sourceUrl, index) => { + const existing = urls.find((entry) => entry.sourceUrl === sourceUrl); + return existing ?? { sourceUrl, ordinal: index, confidence: 'low', warnings: [] }; + }); +} + +function extractSpecificationTable(html: string): Record { + const specs: Record = {}; + + // Extract rows with th/td pairs + const tableMatches = Array.from(html.matchAll(/]*>([\s\S]*?)<\/tr>/gi)); + for (const match of tableMatches) { + const row = match[1]; + const th = row.match(/]*>([\s\S]*?)<\/th>/i)?.[1]?.replace(/<[^>]+>/g, '').trim(); + const td = row.match(/]*>([\s\S]*?)<\/td>/i)?.[1]?.replace(/<[^>]+>/g, '').trim(); + if (th && td && th.length < 80 && td.length < 200) { + specs[th] = td; + } + } + + // Extract
definition lists + const dlMatches = Array.from(html.matchAll(/]*>([\s\S]*?)<\/dl>/gi)); + for (const dl of dlMatches) { + const dtMatches = Array.from(dl[1].matchAll(/]*>([\s\S]*?)<\/dt>/gi)); + const ddMatches = Array.from(dl[1].matchAll(/]*>([\s\S]*?)<\/dd>/gi)); + for (let i = 0; i < Math.min(dtMatches.length, ddMatches.length); i++) { + const key = dtMatches[i][1].replace(/<[^>]+>/g, '').trim(); + const value = ddMatches[i][1].replace(/<[^>]+>/g, '').trim(); + if (key && value && key.length < 80) specs[key] = value; + } + } + + return specs; +} + +function extractLongDescription(html: string): string | undefined { + // Extract expanded content from details/summary and hidden sections + const detailMatches = Array.from(html.matchAll(/]*>([\s\S]*?)<\/details>/gi)); + const parts: string[] = []; + for (const match of detailMatches) { + const content = match[1] + .replace(/]*>[\s\S]*?<\/summary>/gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (content.length > 30) parts.push(content); + } + const combined = parts.join(' ').slice(0, 1200); + return combined || undefined; +} + +function detectPageRegions(html: string): ScrapedPageData['pageRegions'] { + return { + hasGalleryCarousel: /class=["'][^"']*(?:gallery|carousel|slider|swiper)[^"']*["']/i.test(html), + hasSpecificationTable: /<(?:table|dl)[^>]*>[\s\S]*?(?:specification|dimension|weight|material)/i.test(html), + hasVariantSelector: /data-(?:variant|option|color|size)|]*(?:variant|option|color|size)/i.test(html), + hasRecommendationWidget: /class=["'][^"']*(?:recommend|related|you-may-also|similar)[^"']*["']/i.test(html), + }; +} + +export class GenericHtmlAdapter implements IPageScraperAdapter { + readonly supportLevel: ImportSupportLevel; + + constructor(supportLevel: ImportSupportLevel = 'best_effort') { + this.supportLevel = supportLevel; + } + + canHandle(_url: URL): boolean { + return true; // fallback — always handles + } + + async scrape(url: URL): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 7000); + try { + const response = await fetch(url, { + headers: { + 'user-agent': 'MinimalBlockBot/1.0 (+https://minimalblock.demo)', + accept: 'text/html,application/xhtml+xml', + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(response.status === 403 || response.status === 429 ? 'blocked_page' : 'page_unreachable'); + } + + const html = await response.text(); + const jsonLdEntries = parseJsonLd(html); + const productJsonLd = pickProductJsonLd(jsonLdEntries); + + const title = cleanTitle( + typeof productJsonLd?.name === 'string' + ? productJsonLd.name + : extractMeta(html, 'og:title') ?? extractTitleTag(html), + ); + const description = cleanText( + typeof productJsonLd?.description === 'string' + ? productJsonLd.description + : extractMeta(html, 'og:description'), + ); + const longDescription = extractLongDescription(html); + const specificationTable = extractSpecificationTable(html); + const pageRegions = detectPageRegions(html); + + const rawImages: string[] = []; + const jsonLdImage = productJsonLd?.image; + if (typeof jsonLdImage === 'string') rawImages.push(new URL(jsonLdImage, url).toString()); + if (Array.isArray(jsonLdImage)) { + for (const item of jsonLdImage) { + if (typeof item === 'string') rawImages.push(new URL(item, url).toString()); + } + } + const ogImage = extractMeta(html, 'og:image'); + if (ogImage) rawImages.push(new URL(ogImage, url).toString()); + + const visibleImages = extractVisibleImages(html, url); + const mergedImages = [ + ...dedupeUrls(rawImages).map((sourceUrl, index) => ({ + sourceUrl, ordinal: index, confidence: 'high' as const, warnings: [], + })), + ...visibleImages, + ]; + + const uniqueImages: ScrapedImageCandidate[] = dedupeUrls(mergedImages.map((entry) => entry.sourceUrl)) + .slice(0, 8) + .map((sourceUrl, index) => { + const existing = mergedImages.find((entry) => entry.sourceUrl === sourceUrl); + return existing ?? { sourceUrl, ordinal: index, confidence: 'low' as const, warnings: [] }; + }); + + const materialSource = description ?? html; + const materials = extractMaterials(materialSource); + const dimensions = extractDimensions( + Object.values(specificationTable).find((v) => /\d+\s*(?:cm|mm|in)/.test(v)) + ?? description + ?? html, + ); + const categoryHint = typeof productJsonLd?.category === 'string' + ? productJsonLd.category + : extractMeta(html, 'product:category') ?? title; + const price = typeof productJsonLd?.offers === 'object' && productJsonLd.offers + ? `${(productJsonLd.offers as Record).price ?? ''}`.trim() || undefined + : undefined; + + const warnings: string[] = []; + const failureReasons: string[] = []; + if (!description) warnings.push('Description was incomplete and may need editing.'); + if (uniqueImages.length === 0) failureReasons.push('no_product_images_found'); + if (!description) failureReasons.push('no_description_found'); + + const confidence = + (title ? 0.3 : 0) + + (description ? 0.25 : 0) + + (uniqueImages.length > 0 ? 0.25 : 0) + + (materials.length > 0 ? 0.1 : 0) + + (dimensions ? 0.1 : 0); + + return { + sourceUrl: url.toString(), + domain: normalizeDomain(url), + extractionMethod: 'live_scraper', + supportLevel: this.supportLevel, + overallConfidence: Number(confidence.toFixed(2)), + scrapeTimestamp: new Date().toISOString(), + title, + description, + longDescription, + categoryHint, + materials, + dimensions, + price, + images: uniqueImages, + specificationTable: Object.keys(specificationTable).length > 0 ? specificationTable : undefined, + pageRegions, + warnings, + failureReasons, + jsonLdRaw: productJsonLd ?? undefined, + raw: { + titleSource: productJsonLd?.name ? 'jsonld' : extractMeta(html, 'og:title') ? 'og' : 'title', + imageCount: uniqueImages.length, + }, + }; + } catch (error) { + const reason = error instanceof Error && error.message === 'blocked_page' + ? 'blocked_page' + : error instanceof Error && error.name === 'AbortError' + ? 'timeout' + : 'page_unreachable'; + return { + sourceUrl: url.toString(), + domain: normalizeDomain(url), + extractionMethod: 'live_scraper', + supportLevel: this.supportLevel, + overallConfidence: 0, + scrapeTimestamp: new Date().toISOString(), + warnings: ['We could not fully extract this product page.'], + failureReasons: [reason], + images: [], + raw: { error: reason }, + }; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/apps/api/src/lib/import/adapters/ikea.adapter.ts b/apps/api/src/lib/import/adapters/ikea.adapter.ts new file mode 100644 index 0000000..60c4aa7 --- /dev/null +++ b/apps/api/src/lib/import/adapters/ikea.adapter.ts @@ -0,0 +1,17 @@ +import type { ScrapedPageData } from '@minimalblock/core'; +import { GenericHtmlAdapter } from './generic.adapter.js'; + +export class IkeaAdapter extends GenericHtmlAdapter { + override readonly supportLevel = 'supported' as const; + + override canHandle(url: URL): boolean { + return /ikea\.(com|co\.uk|de|fr|it|es|se|no|dk|fi|nl|be|at|ch|pl|pt|cz|sk|hu|ro|hr|si|rs|au|cn|jp|kr|ae|sa)$/.test( + url.hostname.toLowerCase().replace(/^www\./, ''), + ); + } + + override async scrape(url: URL): Promise { + const base = await super.scrape(url); + return base; + } +} diff --git a/apps/api/src/lib/import/adapters/mock.adapter.ts b/apps/api/src/lib/import/adapters/mock.adapter.ts new file mode 100644 index 0000000..195e47d --- /dev/null +++ b/apps/api/src/lib/import/adapters/mock.adapter.ts @@ -0,0 +1,115 @@ +import { Buffer } from 'node:buffer'; +import type { IPageScraperAdapter, ScrapedPageData } from '@minimalblock/core'; + +function normalizeDomain(url: URL): string { + return url.hostname.toLowerCase().replace(/^www\./, ''); +} + +function buildMockDataUrl(label: string, color: string): string { + const svg = `${label}`; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; +} + +export class MockAdapter implements IPageScraperAdapter { + readonly supportLevel = 'mock' as const; + + canHandle(url: URL): boolean { + return normalizeDomain(url) === 'minimalblock.demo'; + } + + async scrape(url: URL): Promise { + const pathname = url.pathname.toLowerCase(); + const now = new Date().toISOString(); + + if (pathname.includes('fail-laptop')) { + return { + sourceUrl: url.toString(), + domain: normalizeDomain(url), + extractionMethod: 'mock_scraper', + supportLevel: 'mock', + overallConfidence: 0.31, + scrapeTimestamp: now, + title: 'UltraSlim Laptop Pro 14"', + categoryHint: 'electronics', + materials: ['aluminum'], + warnings: ['Product page blocked image gallery access.', 'Description could not be extracted cleanly.'], + failureReasons: ['blocked_page', 'no_description_found', 'no_product_images_found'], + images: [], + raw: { mockScenario: 'fail-laptop' }, + }; + } + + if (pathname.includes('warn-lamp')) { + return { + sourceUrl: url.toString(), + domain: normalizeDomain(url), + extractionMethod: 'mock_scraper', + supportLevel: 'mock', + overallConfidence: 0.68, + scrapeTimestamp: now, + title: 'Arc Floor Lamp', + description: 'Minimal arc floor lamp with marble base and adjustable head for reading corners.', + categoryHint: 'home-decor', + materials: ['marble', 'metal'], + dimensions: '180 cm x 40 cm', + price: '$249', + warnings: ['One image looks like a detail crop only.'], + failureReasons: [], + images: [ + { sourceUrl: buildMockDataUrl('Lamp Front', '#2d6a4f'), ordinal: 0, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + { sourceUrl: buildMockDataUrl('Lamp Detail', '#40916c'), ordinal: 1, confidence: 'medium', warnings: ['angle_unclear'], widthPx: 1200, heightPx: 1200 }, + ], + raw: { mockScenario: 'warn-lamp' }, + }; + } + + if (pathname.includes('multi-product')) { + return { + sourceUrl: url.toString(), + domain: normalizeDomain(url), + extractionMethod: 'mock_scraper', + supportLevel: 'mock', + overallConfidence: 0.88, + scrapeTimestamp: now, + title: 'Home Office Bundle — Desk & Chair', + description: 'Complete home office setup featuring a solid oak desk and ergonomic mesh chair.', + categoryHint: 'furniture', + materials: ['wood', 'metal', 'mesh fabric'], + dimensions: 'Desk: 140 cm x 70 cm x 75 cm | Chair: 60 cm x 60 cm x 110 cm', + price: '$699', + warnings: ['Page contains multiple distinct products.'], + failureReasons: [], + images: [ + { sourceUrl: buildMockDataUrl('Desk Front', '#3d405b'), ordinal: 0, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + { sourceUrl: buildMockDataUrl('Desk Side', '#4f5d75'), ordinal: 1, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + { sourceUrl: buildMockDataUrl('Chair Front', '#81b29a'), ordinal: 2, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + { sourceUrl: buildMockDataUrl('Chair Side', '#a8c5b5'), ordinal: 3, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + ], + raw: { mockScenario: 'multi-product-desk-chair' }, + }; + } + + return { + sourceUrl: url.toString(), + domain: normalizeDomain(url), + extractionMethod: 'mock_scraper', + supportLevel: 'mock', + overallConfidence: 0.92, + scrapeTimestamp: now, + title: 'Nordic Accent Chair', + description: 'Scandinavian accent chair with curved oak arms, boucle upholstery, and a compact living-room footprint.', + categoryHint: 'furniture', + materials: ['wood', 'fabric'], + dimensions: '78 cm x 71 cm x 82 cm', + price: '$319', + warnings: ['Imported from mock demo data.'], + failureReasons: [], + images: [ + { sourceUrl: buildMockDataUrl('Chair Front', '#1d3557'), ordinal: 0, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + { sourceUrl: buildMockDataUrl('Chair Side', '#457b9d'), ordinal: 1, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + { sourceUrl: buildMockDataUrl('Chair Back', '#a8dadc'), ordinal: 2, confidence: 'high', warnings: [], widthPx: 1200, heightPx: 1200 }, + ], + raw: { mockScenario: 'success-chair' }, + }; + } +} diff --git a/apps/api/src/lib/import/orchestrator.ts b/apps/api/src/lib/import/orchestrator.ts new file mode 100644 index 0000000..dbaa3b1 --- /dev/null +++ b/apps/api/src/lib/import/orchestrator.ts @@ -0,0 +1,199 @@ +import { + type ImportedField, + type ProductCategory, + type ProductImportData, +} from '@minimalblock/core'; +import { + ANALYSIS_MODEL_ID, + DEFAULT_MODEL_ID, + GeminiImageClassifier, + GeminiMaterialInferenceEngine, + GeminiProductClusterAnalyzer, + createGenerativeModel, +} from '@minimalblock/ai'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '@minimalblock/data'; +import { ScraperAdapterRegistry } from './adapters/adapter-registry.js'; +import { ImageUploadPipeline } from './pipeline/image-upload.pipeline.js'; +import { ImageIntelligencePipeline } from './pipeline/image-intelligence.pipeline.js'; +import { AutofillPipeline, inferCategory, cleanTitle, cleanText } from './pipeline/autofill.pipeline.js'; +import { ClusterDetectionPipeline } from './pipeline/cluster.pipeline.js'; +import { MaterialInferencePipeline } from './pipeline/material.pipeline.js'; + +export interface ExtractionOrchestratorOptions { + admin: SupabaseClient; + ownerId: string; + geminiApiKey: string; +} + +export interface OrchestratorResult { + productName: string; + productDescription: string; + productCategory: ProductCategory; + workflowStatus: 'scrape_failed' | 'autofill_ready'; + importData: ProductImportData; +} + +function normalizeUrl(raw: string): URL { + const trimmed = raw.trim(); + if (!trimmed) throw new Error('Please paste a product URL.'); + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + return new URL(withProtocol); +} + +function buildField( + resolved: T, + scraperValue: T | undefined, + aiValue: T | undefined, + aiConfidence: ImportedField['confidence'] | undefined, +): ImportedField { + if (scraperValue !== undefined && scraperValue !== null && `${scraperValue}` !== '') { + return { + value: resolved, + confidence: scraperValue === resolved ? 'high' : 'medium', + source: scraperValue === resolved ? 'scraper' : 'seller', + aiSuggested: aiValue !== undefined && aiValue !== scraperValue, + originalValue: scraperValue, + }; + } + return { + value: resolved, + confidence: aiConfidence ?? 'low', + source: aiValue !== undefined ? 'ai' : 'seller', + aiSuggested: aiValue !== undefined, + originalValue: aiValue, + }; +} + +export class ExtractionOrchestrator { + private readonly registry: ScraperAdapterRegistry; + private readonly uploadPipeline: ImageUploadPipeline; + private readonly intelligencePipeline: ImageIntelligencePipeline; + private readonly autofillPipeline: AutofillPipeline; + private readonly clusterPipeline: ClusterDetectionPipeline; + private readonly materialPipeline: MaterialInferencePipeline; + + constructor(options: ExtractionOrchestratorOptions) { + const analysisModel = createGenerativeModel(options.geminiApiKey, ANALYSIS_MODEL_ID); + const flashModel = createGenerativeModel(options.geminiApiKey, DEFAULT_MODEL_ID); + + this.registry = new ScraperAdapterRegistry(); + this.uploadPipeline = new ImageUploadPipeline(options.admin, options.ownerId); + this.intelligencePipeline = new ImageIntelligencePipeline(new GeminiImageClassifier(flashModel)); + this.autofillPipeline = new AutofillPipeline(analysisModel); + this.clusterPipeline = new ClusterDetectionPipeline(new GeminiProductClusterAnalyzer(flashModel)); + this.materialPipeline = new MaterialInferencePipeline(new GeminiMaterialInferenceEngine(flashModel)); + } + + async run(rawUrl: string): Promise { + // 1. Resolve adapter and scrape + const url = normalizeUrl(rawUrl); + const adapter = this.registry.resolve(url); + const scrape = await adapter.scrape(url); + + // 2. Upload images to Supabase storage + const uploadedImages = await this.uploadPipeline.upload(scrape.images); + + // 3. Image intelligence — classify, deduplicate, score (graceful fallback) + let imageIntelligenceResult: Awaited> = { + candidates: uploadedImages, + summary: undefined, + }; + try { + imageIntelligenceResult = await this.intelligencePipeline.analyze(uploadedImages, scrape.title); + } catch { + // Proceed without AI image intelligence + } + const enrichedCandidates = imageIntelligenceResult.candidates; + + // 4. Autofill missing product fields + const autofill = await this.autofillPipeline.autofill(scrape, uploadedImages); + const productCategory = autofill.category ?? inferCategory(scrape.categoryHint ?? scrape.title) ?? 'other'; + const titleValue = cleanTitle(scrape.title) ?? autofill.title ?? `Imported product from ${scrape.domain}`; + const descriptionValue = cleanText(scrape.description, 700) ?? autofill.description ?? ''; + const materialsValue = scrape.materials?.length ? scrape.materials : (autofill.materials ?? []); + const dimensionsValue = scrape.dimensions ?? autofill.dimensions ?? ''; + + // 5. Multi-product cluster detection (graceful fallback) + let clusterResult: Awaited> | undefined; + try { + const hasMultiHint = (scrape.title?.includes('&') || scrape.title?.includes('+') || scrape.title?.toLowerCase().includes(' and ') || scrape.title?.toLowerCase().includes('bundle') || scrape.title?.toLowerCase().includes('set')); + if (hasMultiHint || enrichedCandidates.filter((c) => !c.aiRejected).length >= 3) { + clusterResult = await this.clusterPipeline.detect(enrichedCandidates, scrape); + } + } catch { + // Proceed without cluster detection + } + + // 6. Material and geometry inference (graceful fallback) + let materialResult: Awaited> | undefined; + try { + if (enrichedCandidates.some((c) => !c.aiRejected && c.url)) { + materialResult = await this.materialPipeline.infer(enrichedCandidates, scrape); + } + } catch { + // Proceed without material inference + } + + // 7. Assemble importData + const selectedImageIds = enrichedCandidates + .filter((img) => img.storageKey && img.url && !img.failureReasons?.length && !img.aiRejected) + .slice(0, 6) + .map((img) => img.id); + + const failureReasons = [ + ...scrape.failureReasons, + ...(uploadedImages.some((img) => img.storageKey) ? [] : ['no_importable_images']), + ]; + + const importData: ProductImportData = { + sourceUrl: scrape.sourceUrl, + domain: scrape.domain, + scrapeTimestamp: scrape.scrapeTimestamp, + extractionMethod: scrape.extractionMethod, + supportLevel: scrape.supportLevel, + overallConfidence: scrape.overallConfidence, + categoryHint: scrape.categoryHint, + price: scrape.price, + warnings: [ + ...scrape.warnings, + ...(adapter.supportLevel === 'best_effort' ? ['This domain is in best-effort extraction mode.'] : []), + ], + failureReasons, + fields: { + title: buildField(titleValue, scrape.title, autofill.title, autofill.confidenceByField.title), + description: buildField(descriptionValue, scrape.description, autofill.description, autofill.confidenceByField.description), + category: buildField(productCategory, inferCategory(scrape.categoryHint), autofill.category, autofill.confidenceByField.category), + materials: buildField(materialsValue, scrape.materials, autofill.materials, autofill.confidenceByField.materials), + dimensions: buildField(dimensionsValue, scrape.dimensions, autofill.dimensions, autofill.confidenceByField.dimensions), + }, + imageCandidates: enrichedCandidates, + selectedImageIds, + sellerEditedFields: [], + sellerConfirmedText: false, + sellerConfirmedImages: false, + missingFields: autofill.missingFields, + raw: scrape.raw, + // APUS fields + pageRegions: scrape.pageRegions, + imageIntelligence: imageIntelligenceResult.summary, + ...(clusterResult?.multiProductDetected ? { + productClusters: clusterResult.clusters, + primaryClusterId: clusterResult.primaryClusterId, + multiProductDetected: true, + } : {}), + ...(materialResult ? { + inferredMaterialFinish: materialResult.inferredMaterialFinish, + inferredGeometryComplexity: materialResult.inferredGeometryComplexity, + } : {}), + }; + + return { + productName: titleValue, + productDescription: descriptionValue, + productCategory, + workflowStatus: failureReasons.length > 0 && selectedImageIds.length === 0 ? 'scrape_failed' : 'autofill_ready', + importData, + }; + } +} diff --git a/apps/api/src/lib/import/pipeline/autofill.pipeline.ts b/apps/api/src/lib/import/pipeline/autofill.pipeline.ts new file mode 100644 index 0000000..0a79d84 --- /dev/null +++ b/apps/api/src/lib/import/pipeline/autofill.pipeline.ts @@ -0,0 +1,114 @@ +import { buildDeepAutofillPrompt } from '@minimalblock/ai'; + +interface GeminiModel { + generateContent(prompt: string): Promise<{ response: { text(): string } }>; +} +import type { ProductCategory } from '@minimalblock/core'; +import { migrateLegacyProductCategory } from '@minimalblock/core'; +import type { ScrapedPageData } from '@minimalblock/core'; +import type { UploadedImportImage } from './image-upload.pipeline.js'; + +function cleanText(value: string | undefined, maxLength = 800): string | undefined { + if (!value) return undefined; + const compact = value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + if (!compact) return undefined; + return compact.length > maxLength ? `${compact.slice(0, maxLength - 1).trim()}…` : compact; +} + +function cleanTitle(value: string | undefined): string | undefined { + if (!value) return undefined; + const cleaned = value + .replace(/\s*[|\-]\s*(buy|shop|official|store|online).*/i, '') + .replace(/\s*[|\-]\s*[A-Z0-9 .,&]+$/i, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned || undefined; +} + +function inferCategory(value: string | undefined): ProductCategory | undefined { + if (!value) return undefined; + const lowered = value.toLowerCase(); + if (/chair|table|desk|sofa|cabinet|shelf|bed/.test(lowered)) return 'furniture'; + if (/lamp|vase|mirror|decor|rug|pillow/.test(lowered)) return 'home-decor'; + if (/bag|tote|wallet|backpack/.test(lowered)) return 'bags'; + if (/watch|belt|jewelry|glasses|accessory/.test(lowered)) return 'accessories'; + if (/laptop|phone|headphone|speaker|tablet|electronic|monitor/.test(lowered)) return 'electronics'; + return migrateLegacyProductCategory(lowered); +} + +function extractMaterials(value: string | undefined): string[] { + if (!value) return []; + const matches = value.match(/\b(leather|wood|metal|glass|ceramic|plastic|cotton|linen|marble|steel|aluminum|fabric)\b/gi) ?? []; + return Array.from(new Set(matches.map((item) => item.toLowerCase()))); +} + +function extractDimensions(value: string | undefined): string | undefined { + if (!value) return undefined; + const match = value.match(/(\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m)\s?(?:x|×)\s?\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m)(?:\s?(?:x|×)\s?\d+(?:\.\d+)?\s?(?:cm|mm|in|inch|inches|m))?)/i); + return match?.[1]; +} + +export interface AutofillResult { + title?: string; + category?: ProductCategory; + materials?: string[]; + dimensions?: string; + description?: string; + missingFields: string[]; + confidenceByField: Partial>; +} + +export class AutofillPipeline { + constructor(private readonly model: GeminiModel) {} + + async autofill(scrape: ScrapedPageData, images: UploadedImportImage[]): Promise { + try { + const prompt = buildDeepAutofillPrompt({ + title: scrape.title, + description: scrape.description, + longDescription: scrape.longDescription, + categoryHint: scrape.categoryHint, + materials: scrape.materials, + dimensions: scrape.dimensions, + specTable: scrape.specificationTable, + imageAlts: images.map((img) => img.alt ?? img.title ?? img.storageKey ?? 'image'), + }); + + const result = await this.model.generateContent(prompt); + const raw = result.response.text().trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, ''); + const parsed = JSON.parse(raw) as AutofillResult; + + return { + title: parsed.title ? cleanTitle(parsed.title) : undefined, + category: parsed.category ? inferCategory(parsed.category) : undefined, + materials: parsed.materials ?? [], + dimensions: parsed.dimensions, + description: parsed.description ? cleanText(parsed.description, 700) : undefined, + missingFields: parsed.missingFields ?? [], + confidenceByField: parsed.confidenceByField ?? {}, + }; + } catch { + return { + title: cleanTitle(scrape.title), + category: inferCategory(scrape.categoryHint ?? scrape.title), + materials: scrape.materials?.length ? scrape.materials : extractMaterials(scrape.description), + dimensions: scrape.dimensions ?? extractDimensions(scrape.description), + description: cleanText(scrape.description, 700), + missingFields: [ + ...(scrape.description ? [] : ['description']), + ...(images.length > 0 ? [] : ['images']), + ...(scrape.dimensions ? [] : ['dimensions']), + ], + confidenceByField: { + title: scrape.title ? 'medium' : 'low', + category: scrape.categoryHint ? 'medium' : 'low', + materials: scrape.materials?.length ? 'medium' : 'low', + dimensions: scrape.dimensions ? 'medium' : 'low', + description: scrape.description ? 'medium' : 'low', + }, + }; + } + } +} + +export { inferCategory, cleanTitle, cleanText, extractMaterials, extractDimensions }; diff --git a/apps/api/src/lib/import/pipeline/cluster.pipeline.ts b/apps/api/src/lib/import/pipeline/cluster.pipeline.ts new file mode 100644 index 0000000..431a91f --- /dev/null +++ b/apps/api/src/lib/import/pipeline/cluster.pipeline.ts @@ -0,0 +1,77 @@ +import { generateId, type ImportedImageCandidate, type ProductCluster, type ImportFieldConfidence, type ProductCategory } from '@minimalblock/core'; +import { GeminiProductClusterAnalyzer, type MultiProductDetectionInput } from '@minimalblock/ai'; +import type { ScrapedPageData } from '@minimalblock/core'; + +export interface ClusterDetectionResult { + clusters: ProductCluster[]; + primaryClusterId: string; + multiProductDetected: boolean; +} + +export class ClusterDetectionPipeline { + constructor(private readonly analyzer: GeminiProductClusterAnalyzer) {} + + async detect( + candidates: ImportedImageCandidate[], + scrape: ScrapedPageData, + ): Promise { + const nonRejected = candidates.filter((c) => !c.aiRejected && c.url && c.mimeType); + const acceptedImages: Array<{ base64: string; mimeType: string }> = []; + + for (const candidate of nonRejected.slice(0, 6)) { + if (!candidate.url || !candidate.mimeType || candidate.mimeType === 'image/svg+xml') continue; + try { + const res = await fetch(candidate.url, { + headers: { 'user-agent': 'MinimalBlockBot/1.0', accept: 'image/*' }, + }); + if (!res.ok) continue; + const buf = Buffer.from(await res.arrayBuffer()); + acceptedImages.push({ base64: buf.toString('base64'), mimeType: candidate.mimeType }); + } catch { + // skip unfetchable images + } + } + + const input: MultiProductDetectionInput = { + title: scrape.title, + description: scrape.description, + imageCount: acceptedImages.length || candidates.length, + specTableKeys: scrape.specificationTable ? Object.keys(scrape.specificationTable) : undefined, + }; + + const result = await this.analyzer.analyze(acceptedImages, input); + + const clusters: ProductCluster[] = result.clusters.map((detectedCluster) => { + const clusterId = generateId(); + // Map image indexes back to candidate IDs from accepted images + const imageIds: string[] = detectedCluster.imageIndexes + .map((idx) => nonRejected[idx]?.id) + .filter((id): id is string => id !== undefined); + + return { + clusterId, + clusterLabel: detectedCluster.label, + confidence: detectedCluster.confidence as ImportFieldConfidence, + imageIds: imageIds.length > 0 ? imageIds : candidates.map((c) => c.id), + fields: { + ...(detectedCluster.fieldHints.title ? { + title: { value: detectedCluster.fieldHints.title, confidence: detectedCluster.confidence as ImportFieldConfidence, source: 'ai' as const }, + } : {}), + ...(detectedCluster.fieldHints.category ? { + category: { value: detectedCluster.fieldHints.category as ProductCategory, confidence: detectedCluster.confidence as ImportFieldConfidence, source: 'ai' as const }, + } : {}), + ...(detectedCluster.fieldHints.materials?.length ? { + materials: { value: detectedCluster.fieldHints.materials, confidence: detectedCluster.confidence as ImportFieldConfidence, source: 'ai' as const }, + } : {}), + ...(detectedCluster.fieldHints.dimensions ? { + dimensions: { value: detectedCluster.fieldHints.dimensions, confidence: detectedCluster.confidence as ImportFieldConfidence, source: 'ai' as const }, + } : {}), + }, + }; + }); + + const primaryClusterId = clusters[0]?.clusterId ?? generateId(); + + return { clusters, primaryClusterId, multiProductDetected: result.multiProductDetected }; + } +} diff --git a/apps/api/src/lib/import/pipeline/image-intelligence.pipeline.ts b/apps/api/src/lib/import/pipeline/image-intelligence.pipeline.ts new file mode 100644 index 0000000..39cc00e --- /dev/null +++ b/apps/api/src/lib/import/pipeline/image-intelligence.pipeline.ts @@ -0,0 +1,111 @@ +import { Buffer } from 'node:buffer'; +import type { ImportedImageCandidate, ProductImportData } from '@minimalblock/core'; +import { GeminiImageClassifier, ImageDeduplicationService } from '@minimalblock/ai'; + +export interface ImageIntelligenceResult { + candidates: ImportedImageCandidate[]; + summary: ProductImportData['imageIntelligence']; +} + +export class ImageIntelligencePipeline { + private readonly deduplicator = new ImageDeduplicationService(); + + constructor(private readonly classifier: GeminiImageClassifier) {} + + async analyze( + candidates: ImportedImageCandidate[], + productTitleHint?: string, + ): Promise { + const totalBefore = candidates.length; + if (candidates.length === 0) { + return { + candidates, + summary: { totalCandidatesBeforeFiltering: 0, rejectedByAi: 0, duplicatesRemoved: 0, variantImagesDetected: 0 }, + }; + } + + // Fetch all candidate images into buffers for hashing + const buffers = await Promise.all( + candidates.map(async (candidate) => { + if (!candidate.url) return null; + try { + const res = await fetch(candidate.url, { + headers: { 'user-agent': 'MinimalBlockBot/1.0', accept: 'image/*' }, + }); + if (!res.ok) return null; + return Buffer.from(await res.arrayBuffer()); + } catch { + return null; + } + }), + ); + + // Perceptual deduplication + const hashes = buffers.map((buf) => buf ? this.deduplicator.computeHash(new Uint8Array(buf)) : '0000000000000000'); + const duplicateIndexes = new Set(this.deduplicator.findDuplicates(hashes)); + + // Build base64 images for Gemini classification (only non-failed uploads) + const geminiImages: Array<{ base64: string; mimeType: string; originalIndex: number }> = []; + for (let i = 0; i < candidates.length; i++) { + const buf = buffers[i]; + if (buf && candidates[i].mimeType && candidates[i].mimeType !== 'image/svg+xml') { + geminiImages.push({ + base64: buf.toString('base64'), + mimeType: candidates[i].mimeType!, + originalIndex: i, + }); + } + } + + // Single Gemini call for all images + let classifications: Awaited> = []; + try { + classifications = await this.classifier.classifyBatch( + geminiImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })), + productTitleHint, + ); + } catch { + // Graceful fallback: proceed without AI classification + } + + let rejectedCount = 0; + let variantCount = 0; + + const enriched: ImportedImageCandidate[] = candidates.map((candidate, originalIndex) => { + const geminiIdx = geminiImages.findIndex((g) => g.originalIndex === originalIndex); + const classification = geminiIdx >= 0 ? classifications[geminiIdx] : undefined; + const isDuplicate = duplicateIndexes.has(originalIndex); + const isRejected = isDuplicate || (classification?.rejected ?? false); + + if (isRejected) rejectedCount++; + if (classification?.rejectionReason?.startsWith('variant:') || candidate.variantKey) variantCount++; + + return { + ...candidate, + perceptualHash: hashes[originalIndex] !== '0000000000000000' ? hashes[originalIndex] : undefined, + aiImageClass: classification?.imageClass, + aiRelevanceScore: classification?.relevanceScore, + aiRejected: isRejected, + aiRejectionReason: isDuplicate ? 'duplicate' : classification?.rejectionReason, + viewAngle: classification?.viewAngle, + }; + }); + + // Sort: hero/detail first by relevance, rejected last + const sorted = [...enriched].sort((a, b) => { + if (a.aiRejected && !b.aiRejected) return 1; + if (!a.aiRejected && b.aiRejected) return -1; + return (b.aiRelevanceScore ?? 0.5) - (a.aiRelevanceScore ?? 0.5); + }); + + return { + candidates: sorted, + summary: { + totalCandidatesBeforeFiltering: totalBefore, + rejectedByAi: rejectedCount, + duplicatesRemoved: duplicateIndexes.size, + variantImagesDetected: variantCount, + }, + }; + } +} diff --git a/apps/api/src/lib/import/pipeline/image-upload.pipeline.ts b/apps/api/src/lib/import/pipeline/image-upload.pipeline.ts new file mode 100644 index 0000000..fb60262 --- /dev/null +++ b/apps/api/src/lib/import/pipeline/image-upload.pipeline.ts @@ -0,0 +1,108 @@ +import { Buffer } from 'node:buffer'; +import { generateId, type ImportedImageCandidate, type MediaAssetType } from '@minimalblock/core'; +import type { ScrapedImageCandidate } from '@minimalblock/core'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '@minimalblock/data'; + +function slugify(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 48) || 'product'; +} + +function mimeExtension(mimeType: string): string { + switch (mimeType) { + case 'image/png': return 'png'; + case 'image/svg+xml': return 'svg'; + case 'image/webp': return 'webp'; + default: return 'jpg'; + } +} + +function decodeDataUrl(raw: string): { mimeType: string; buffer: Buffer } | null { + if (!raw.startsWith('data:')) return null; + const match = raw.match(/^data:([^;]+);base64,(.+)$/); + if (!match) return null; + return { mimeType: match[1], buffer: Buffer.from(match[2], 'base64') }; +} + +export type UploadedImportImage = ImportedImageCandidate; + +export class ImageUploadPipeline { + constructor( + private readonly admin: SupabaseClient, + private readonly ownerId: string, + ) {} + + async upload(images: ScrapedImageCandidate[]): Promise { + const uploaded: UploadedImportImage[] = []; + for (const image of images) { + try { + const result = await this.fetchAndUpload(image); + uploaded.push(result); + } catch (error) { + uploaded.push({ + id: generateId(), + sourceUrl: image.sourceUrl, + ordinal: image.ordinal, + selected: false, + warnings: image.warnings, + confidence: image.confidence, + widthPx: image.widthPx, + heightPx: image.heightPx, + alt: image.alt, + title: image.title, + failureReasons: [error instanceof Error ? error.message : 'image_download_failed'], + }); + } + } + return uploaded; + } + + private async fetchAndUpload(image: ScrapedImageCandidate): Promise { + const decoded = decodeDataUrl(image.sourceUrl); + const response = decoded + ? null + : await fetch(image.sourceUrl, { + headers: { + 'user-agent': 'MinimalBlockBot/1.0 (+https://minimalblock.demo)', + accept: 'image/*', + }, + }); + + const mimeType = decoded?.mimeType ?? response?.headers.get('content-type')?.split(';')[0] ?? ''; + if (!mimeType.startsWith('image/')) throw new Error('non_image_response'); + + const normalizedMimeType: MediaAssetType = + mimeType === 'image/png' || mimeType === 'image/webp' || mimeType === 'image/svg+xml' + ? mimeType + : 'image/jpeg'; + + const bytes = decoded?.buffer ?? Buffer.from(await response!.arrayBuffer()); + const fileName = `${Date.now()}-${slugify(image.title ?? image.alt ?? `import-${image.ordinal}`)}.${mimeExtension(normalizedMimeType)}`; + const storageKey = `${this.ownerId}/imports/${fileName}`; + + const { error } = await this.admin.storage.from('media-assets').upload(storageKey, bytes, { + contentType: normalizedMimeType, + upsert: false, + }); + if (error) throw new Error('image_upload_failed'); + + const { data } = this.admin.storage.from('media-assets').getPublicUrl(storageKey); + + return { + id: generateId(), + sourceUrl: image.sourceUrl, + url: data.publicUrl, + storageKey, + mimeType: normalizedMimeType, + sizeBytes: bytes.byteLength, + ordinal: image.ordinal, + selected: true, + warnings: image.warnings, + confidence: image.confidence, + widthPx: image.widthPx, + heightPx: image.heightPx, + alt: image.alt, + title: image.title, + }; + } +} diff --git a/apps/api/src/lib/import/pipeline/material.pipeline.ts b/apps/api/src/lib/import/pipeline/material.pipeline.ts new file mode 100644 index 0000000..25ae17e --- /dev/null +++ b/apps/api/src/lib/import/pipeline/material.pipeline.ts @@ -0,0 +1,47 @@ +import type { ImportedImageCandidate, MaterialFinish, GeometryComplexity } from '@minimalblock/core'; +import { GeminiMaterialInferenceEngine } from '@minimalblock/ai'; +import type { ScrapedPageData } from '@minimalblock/core'; + +export interface MaterialInferenceOutput { + inferredMaterialFinish: MaterialFinish; + inferredGeometryComplexity: GeometryComplexity; +} + +export class MaterialInferencePipeline { + constructor(private readonly engine: GeminiMaterialInferenceEngine) {} + + async infer( + candidates: ImportedImageCandidate[], + scrape: ScrapedPageData, + ): Promise { + const heroCandidates = candidates + .filter((c) => !c.aiRejected && c.url && c.mimeType && c.mimeType !== 'image/svg+xml') + .sort((a, b) => (b.aiRelevanceScore ?? 0.5) - (a.aiRelevanceScore ?? 0.5)) + .slice(0, 3); + + const heroImages: Array<{ base64: string; mimeType: string }> = []; + for (const candidate of heroCandidates) { + if (!candidate.url || !candidate.mimeType) continue; + try { + const res = await fetch(candidate.url, { + headers: { 'user-agent': 'MinimalBlockBot/1.0', accept: 'image/*' }, + }); + if (!res.ok) continue; + const buf = Buffer.from(await res.arrayBuffer()); + heroImages.push({ base64: buf.toString('base64'), mimeType: candidate.mimeType }); + } catch { + // skip + } + } + + const result = await this.engine.infer(heroImages, { + productTitle: scrape.title, + knownMaterials: scrape.materials, + }); + + return { + inferredMaterialFinish: result.materialFinish, + inferredGeometryComplexity: result.geometryComplexity, + }; + } +} diff --git a/apps/api/src/lib/product-import.service.ts b/apps/api/src/lib/product-import.service.ts new file mode 100644 index 0000000..73d9e7a --- /dev/null +++ b/apps/api/src/lib/product-import.service.ts @@ -0,0 +1,35 @@ +import { MediaAsset, type ProductImportData } from '@minimalblock/core'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '@minimalblock/data'; +import { ExtractionOrchestrator, type OrchestratorResult } from './import/orchestrator.js'; + +export interface ProductImportServiceOptions { + admin: SupabaseClient; + ownerId: string; + geminiApiKey: string; +} + +export class ProductImportService { + private readonly orchestrator: ExtractionOrchestrator; + + constructor(options: ProductImportServiceOptions) { + this.orchestrator = new ExtractionOrchestrator(options); + } + + async importFromUrl(rawUrl: string): Promise { + return this.orchestrator.run(rawUrl); + } + + static toImportedMediaAssets(importData: ProductImportData | null): MediaAsset[] { + if (!importData) return []; + return importData.imageCandidates + .filter((candidate) => candidate.selected && candidate.storageKey && candidate.url && candidate.mimeType && candidate.sizeBytes !== undefined) + .map((candidate) => new MediaAsset({ + url: candidate.url!, + storageKey: candidate.storageKey!, + mimeType: candidate.mimeType!, + kind: 'source-image', + sizeBytes: candidate.sizeBytes!, + })); + } +} diff --git a/apps/api/src/lib/server.ts b/apps/api/src/lib/server.ts index bf7722c..bae4943 100644 --- a/apps/api/src/lib/server.ts +++ b/apps/api/src/lib/server.ts @@ -6,7 +6,10 @@ import { Product, GenerationJob, MediaAsset, + ProductWorkflowStatus, QualityReport, + SourceImageReadiness, + deriveViewLabel, generateId, migrateLegacyProductCategory, type AnalyzeProductRequest, @@ -21,13 +24,21 @@ import { type GenerateHotspotsRequest, type GenerateHotspotsResponse, type GeminiQaResult, + type ImportProductUrlRequest, + type ImportProductUrlResponse, type ProductAiAnalysis, type ProductAiCopy, + type ProductImportSnapshot, type QualityCheckRequest, type QualityCheckResponse, type RejectConversionRequest, + type AcceptProductClusterRequest, + type AcceptProductClusterResponse, + type RetryImportedProductResponse, type ReturnRiskRequest, type ReturnRiskResponse, + type SaveImportedReviewRequest, + type SaveImportedReviewResponse, type SuggestedHotspot, type SuggestedHotspotType, } from '@minimalblock/core'; @@ -37,8 +48,9 @@ import { SupabaseGenerationJobRepository, SupabaseProductRepository, } from '@minimalblock/data'; -import { createGenerativeModel, ANALYSIS_MODEL_ID, GeminiModelGenerator, GeminiVisualQa, buildTrendyolListingPrompt } from '@minimalblock/ai'; +import { createGenerativeModel, ANALYSIS_MODEL_ID, GeminiModelGenerator, GeminiVisualQa, buildTrendyolListingPrompt, GenerationFeedbackService } from '@minimalblock/ai'; import { TrendyolClient } from '@minimalblock/trendyol'; +import { ProductImportService } from './product-import.service.js'; import type { BatchResult as TrendyolBatchResult, ShipmentPackagesParams, @@ -297,6 +309,18 @@ function toConversionSnapshot(conversion: Conversion): ConversionSnapshot { }; } +function toProductImportSnapshot(product: Product): ProductImportSnapshot { + return { + productId: product.id, + name: product.name, + description: product.description, + category: product.category, + workflowStatus: product.workflowStatus, + inputMethod: product.inputMethod, + importData: product.importData, + }; +} + async function getOwnedProduct(ctx: RequestContext, productId: string): Promise { const productRepo = new SupabaseProductRepository(ctx.admin); const product = await productRepo.findById(productId); @@ -336,7 +360,11 @@ function mergeAnalysis(product: Product, patch: Partial): Pro }; } -async function analyzeProductWithGemini(ctx: RequestContext, product: Product, sourceAsset?: MediaAsset): Promise { +function getImportedSourceAssets(product: Product): MediaAsset[] { + return ProductImportService.toImportedMediaAssets(product.importData); +} + +async function analyzeProductWithGemini(ctx: RequestContext, product: Product, sourceAssets: MediaAsset[] = []): Promise { const model = createGenerativeModel(ctx.env.geminiApiKey, ANALYSIS_MODEL_ID); const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [ { @@ -357,7 +385,8 @@ async function analyzeProductWithGemini(ctx: RequestContext, product: Product, s }, ]; - if (sourceAsset) { + const GEMINI_SUPPORTED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']); + for (const sourceAsset of sourceAssets.filter((a) => GEMINI_SUPPORTED_MIME.has(a.mimeType)).slice(0, 3)) { parts.push({ inlineData: await fetchAssetBase64(sourceAsset) }); } @@ -389,7 +418,7 @@ async function analyzeProductWithGemini(ctx: RequestContext, product: Product, s }; } -async function generateSuggestedHotspots(ctx: RequestContext, product: Product, sourceAsset?: MediaAsset): Promise { +async function generateSuggestedHotspots(ctx: RequestContext, product: Product, sourceAssets: MediaAsset[] = []): Promise { const model = createGenerativeModel(ctx.env.geminiApiKey, ANALYSIS_MODEL_ID); const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [ { @@ -399,7 +428,8 @@ async function generateSuggestedHotspots(ctx: RequestContext, product: Product, `Product: ${product.name}\nCategory: ${product.category}\nDescription: ${product.description || 'n/a'}`, }, ]; - if (sourceAsset) { + const GEMINI_SUPPORTED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']); + for (const sourceAsset of sourceAssets.filter((a) => GEMINI_SUPPORTED_MIME.has(a.mimeType)).slice(0, 2)) { parts.push({ inlineData: await fetchAssetBase64(sourceAsset) }); } const result = await model.generateContent(parts); @@ -413,6 +443,247 @@ async function generateSuggestedHotspots(ctx: RequestContext, product: Product, })); } +function importedReadiness(product: Product): SourceImageReadiness | null { + if (!product.importData) return null; + const entries = product.importData.imageCandidates + .filter((candidate) => candidate.selected && candidate.storageKey && candidate.url && candidate.sizeBytes !== undefined) + .map((candidate) => ({ + storageKey: candidate.storageKey!, + url: candidate.url!, + sizeBytes: candidate.sizeBytes!, + viewLabel: deriveViewLabel(candidate.storageKey!), + widthPx: candidate.widthPx, + heightPx: candidate.heightPx, + warnings: candidate.warnings.filter((warning) => + warning === 'low_resolution' + || warning === 'likely_duplicate' + || warning === 'likely_cropped' + || warning === 'background_inconsistent' + || warning === 'angle_unclear', + ) as Array<'low_resolution' | 'likely_duplicate' | 'likely_cropped' | 'background_inconsistent' | 'angle_unclear'>, + })); + + return entries.length > 0 ? SourceImageReadiness.fromEntries(entries) : null; +} + +async function handleImportProductUrl(ctx: RequestContext, req: ImportProductUrlRequest): Promise { + if (!req.url?.trim()) throw new Error('Invalid request'); + + const productRepo = new SupabaseProductRepository(ctx.admin); + const eventsRepo = new SupabaseEventsRepository(ctx.admin); + const now = new Date(); + + let product = await productRepo.save( + new Product({ + id: generateId(), + name: 'Importing product…', + description: '', + category: 'other', + ownerId: ctx.user.id, + hotspots: [], + hotspotsSuggested: [], + aiAnalysis: null, + workflowStatus: 'url_submitted', + inputMethod: 'url_import', + importData: null, + createdAt: now, + updatedAt: now, + }), + ); + + await eventsRepo.track(product.id, ctx.user.id, 'import_url_submitted', { source_url: req.url }); + product = await productRepo.save(product.withWorkflowStatus('scraping')); + await eventsRepo.track(product.id, ctx.user.id, 'import_scrape_started'); + + const service = new ProductImportService({ + admin: ctx.admin, + ownerId: ctx.user.id, + geminiApiKey: ctx.env.geminiApiKey, + }); + const imported = await service.importFromUrl(req.url); + + product = await productRepo.save( + product + .withUpdatedMeta({ + name: imported.productName, + description: imported.productDescription, + category: imported.productCategory, + }) + .withImportData(imported.importData) + .withWorkflowStatus(imported.workflowStatus), + ); + + if (imported.workflowStatus === 'scrape_failed') { + await eventsRepo.track(product.id, ctx.user.id, 'import_scrape_failed', { reasons: imported.importData.failureReasons }); + } else { + await eventsRepo.track(product.id, ctx.user.id, 'import_scrape_completed', { confidence: imported.importData.overallConfidence }); + await eventsRepo.track(product.id, ctx.user.id, 'import_images_extracted', { count: imported.importData.imageCandidates.length }); + await eventsRepo.track(product.id, ctx.user.id, 'import_autofill_completed', { missing_fields: imported.importData.missingFields ?? [] }); + } + + return { product: toProductImportSnapshot(product) }; +} + +async function handleSaveImportedReview( + ctx: RequestContext, + productId: string, + req: SaveImportedReviewRequest, +): Promise { + if (!req.title?.trim() || !req.selectedImageIds?.length || !req.sellerConfirmedText || !req.sellerConfirmedImages) { + throw new Error('Invalid request'); + } + + const productRepo = new SupabaseProductRepository(ctx.admin); + const eventsRepo = new SupabaseEventsRepository(ctx.admin); + const product = await getOwnedProduct(ctx, productId); + if (!product.importData) throw new Error('Invalid request'); + + const reviewed = product.withImportedReview({ + name: req.title.trim(), + description: req.description.trim(), + category: req.category, + materials: req.materials, + dimensions: req.dimensions.trim(), + selectedImageIds: req.selectedImageIds, + sellerConfirmedText: req.sellerConfirmedText, + sellerConfirmedImages: req.sellerConfirmedImages, + }).withWorkflowStatus('source_readiness_pending'); + + const sourceAssets = getImportedSourceAssets(reviewed); + if (sourceAssets.length === 0) throw new Error('Invalid request'); + + const readiness = importedReadiness(reviewed) ?? SourceImageReadiness.fromMediaAssets(sourceAssets); + const aiAnalysis = await analyzeProductWithGemini(ctx, reviewed, sourceAssets); + const combinedReadinessScore = Math.round(((aiAnalysis.readinessScore ?? readiness.score) + readiness.score) / 2); + const normalizedReadinessScore = readiness.hasEnoughUniqueViews ? combinedReadinessScore : Math.min(combinedReadinessScore, 65); + const merged = mergeAnalysis(reviewed, { + ...aiAnalysis, + materials: req.materials, + readinessScore: normalizedReadinessScore, + finalQualityScore: aiAnalysis.finalQualityScore ?? normalizedReadinessScore, + visualMatchScore: aiAnalysis.visualMatchScore ?? normalizedReadinessScore, + commerceReadinessScore: aiAnalysis.commerceReadinessScore ?? normalizedReadinessScore, + missingVisuals: readiness.missingViews.map((view) => `${view} view`), + qualityRecommendations: Array.from(new Set([ + ...readiness.weakImages.flatMap((image) => image.warnings.map((warning) => `Fix ${warning.replace(/_/g, ' ')} on ${image.storageKey.split('/').pop()}`)), + ...aiAnalysis.qualityRecommendations, + ])), + merchantRecommendations: Array.from(new Set([ + ...(reviewed.importData?.warnings ?? []), + ...aiAnalysis.merchantRecommendations, + ])), + sourceImageEntries: readiness.entries, + }); + + const saved = await productRepo.save( + reviewed + .withAiAnalysis(merged) + .withWorkflowStatus(ProductWorkflowStatus.deriveFromAiAnalysis(merged).value), + ); + + await eventsRepo.track(saved.id, ctx.user.id, 'import_images_selected', { count: req.selectedImageIds.length }); + if (saved.importData?.sellerEditedFields.length) { + await eventsRepo.track(saved.id, ctx.user.id, 'import_fields_edited', { fields: saved.importData.sellerEditedFields }); + } + await eventsRepo.track(saved.id, ctx.user.id, 'import_moved_to_source_readiness', { readiness_score: normalizedReadinessScore }); + + return { + product: toProductImportSnapshot(saved), + selectedImages: saved.importData?.imageCandidates.filter((candidate) => candidate.selected) ?? [], + readinessScore: normalizedReadinessScore, + }; +} + +async function handleAcceptProductCluster( + ctx: RequestContext, + productId: string, + req: AcceptProductClusterRequest, +): Promise { + if (!req.clusterId) throw new Error('Invalid request'); + + const productRepo = new SupabaseProductRepository(ctx.admin); + const product = await getOwnedProduct(ctx, productId); + + if (!product.importData?.multiProductDetected || !product.importData.productClusters?.length) { + throw new Error('Product does not have multi-product clusters'); + } + + const cluster = product.importData.productClusters.find((c) => c.clusterId === req.clusterId); + if (!cluster) throw new Error('Cluster not found'); + + const clusterImageIds = new Set(cluster.imageIds); + const scopedCandidates = product.importData.imageCandidates.map((candidate) => ({ + ...candidate, + selected: clusterImageIds.has(candidate.id), + })); + + const scopedImportData = { + ...product.importData, + fields: { + ...product.importData.fields, + ...(cluster.fields.title ? { title: cluster.fields.title } : {}), + ...(cluster.fields.description ? { description: cluster.fields.description } : {}), + ...(cluster.fields.category ? { category: cluster.fields.category } : {}), + ...(cluster.fields.materials ? { materials: cluster.fields.materials } : {}), + ...(cluster.fields.dimensions ? { dimensions: cluster.fields.dimensions } : {}), + }, + imageCandidates: scopedCandidates, + selectedImageIds: cluster.imageIds, + multiProductDetected: false, + productClusters: undefined, + primaryClusterId: cluster.clusterId, + ...(cluster.materialFinish ? { inferredMaterialFinish: cluster.materialFinish } : {}), + ...(cluster.geometryComplexity ? { inferredGeometryComplexity: cluster.geometryComplexity } : {}), + }; + + const clusterName = cluster.fields.title?.value ?? product.name; + const clusterCategory = cluster.fields.category?.value ?? product.category; + const saved = await productRepo.save( + product + .withImportData(scopedImportData) + .withUpdatedMeta({ name: clusterName, category: clusterCategory }), + ); + + return { product: toProductImportSnapshot(saved) }; +} + +async function handleRetryImportedProduct(ctx: RequestContext, productId: string): Promise { + const productRepo = new SupabaseProductRepository(ctx.admin); + const eventsRepo = new SupabaseEventsRepository(ctx.admin); + const existing = await getOwnedProduct(ctx, productId); + const sourceUrl = existing.importData?.sourceUrl; + if (!sourceUrl) throw new Error('Invalid request'); + + await eventsRepo.track(existing.id, ctx.user.id, 'import_scrape_started', { retry: true }); + let product = await productRepo.save(existing.withWorkflowStatus('scraping')); + const service = new ProductImportService({ + admin: ctx.admin, + ownerId: ctx.user.id, + geminiApiKey: ctx.env.geminiApiKey, + }); + const imported = await service.importFromUrl(sourceUrl); + product = await productRepo.save( + product + .withUpdatedMeta({ + name: imported.productName, + description: imported.productDescription, + category: imported.productCategory, + }) + .withImportData(imported.importData) + .withWorkflowStatus(imported.workflowStatus), + ); + + if (imported.workflowStatus === 'scrape_failed') { + await eventsRepo.track(product.id, ctx.user.id, 'import_scrape_failed', { reasons: imported.importData.failureReasons, retry: true }); + } else { + await eventsRepo.track(product.id, ctx.user.id, 'import_scrape_completed', { confidence: imported.importData.overallConfidence, retry: true }); + await eventsRepo.track(product.id, ctx.user.id, 'import_images_extracted', { count: imported.importData.imageCandidates.length, retry: true }); + await eventsRepo.track(product.id, ctx.user.id, 'import_autofill_completed', { missing_fields: imported.importData.missingFields ?? [], retry: true }); + } + + return { product: toProductImportSnapshot(product) }; +} + async function generateSuggestedCopy(ctx: RequestContext, product: Product): Promise { const model = createGenerativeModel(ctx.env.geminiApiKey, ANALYSIS_MODEL_ID); const result = await model.generateContent( @@ -433,31 +704,13 @@ async function generateReturnRisk(ctx: RequestContext, product: Product): Promis return parseJsonText>(result.response.text()); } -async function handleCreateConversion(ctx: RequestContext, req: CreateConversionRequest): Promise { - if (!req.product?.name || !req.sourceAssets?.length) { - throw new Error('Invalid request'); - } - - const productRepo = new SupabaseProductRepository(ctx.admin); +async function createConversionForProduct( + ctx: RequestContext, + product: Product, + req: Pick, +): Promise { const conversionRepo = new SupabaseConversionRepository(ctx.admin); const jobRepo = new SupabaseGenerationJobRepository(ctx.admin); - - const now = new Date(); - const product = await productRepo.save( - new Product({ - id: generateId(), - name: req.product.name, - description: req.product.description ?? '', - category: migrateLegacyProductCategory(req.product.category), - ownerId: ctx.user.id, - hotspots: [], - hotspotsSuggested: [], - aiAnalysis: null, - createdAt: now, - updatedAt: now, - }), - ); - const sourceAssets = req.sourceAssets.map((asset) => toMediaAsset(asset, 'source-image')); let conversion = Conversion.create(generateId(), product.id, ctx.user.id, sourceAssets[0], sourceAssets); conversion = await conversionRepo.save(conversion); @@ -489,11 +742,19 @@ async function handleCreateConversion(ctx: RequestContext, req: CreateConversion }), ); } else { - const generator = new GeminiModelGenerator(createGenerativeModel(ctx.env.geminiApiKey)); + const generator = new GeminiModelGenerator( + createGenerativeModel(ctx.env.geminiApiKey), + createGenerativeModel(ctx.env.geminiApiKey, ANALYSIS_MODEL_ID), + ); const generated = await generator.generate({ - sourceAsset: sourceAssets[0], - productCategory: product.category, - qualityHint: req.qualityHint, + sourceAsset: sourceAssets[0], + sourceAssets: sourceAssets, + productCategory: product.category, + qualityHint: req.qualityHint, + productTitle: product.name, + productDimensions: product.importData?.fields?.dimensions?.value ?? undefined, + inferredMaterialFinish: product.importData?.inferredMaterialFinish ?? undefined, + inferredGeometryComplexity: product.importData?.inferredGeometryComplexity ?? undefined, }); outputAsset = await uploadGeneratedModel(ctx.admin, ctx.user.id, generated.outputAsset, product.name); job = await jobRepo.save( @@ -551,6 +812,48 @@ async function handleCreateConversion(ctx: RequestContext, req: CreateConversion }; } +async function handleCreateConversion(ctx: RequestContext, req: CreateConversionRequest): Promise { + if (!req.product?.name || !req.sourceAssets?.length) { + throw new Error('Invalid request'); + } + + const productRepo = new SupabaseProductRepository(ctx.admin); + + const now = new Date(); + const product = await productRepo.save( + new Product({ + id: generateId(), + name: req.product.name, + description: req.product.description ?? '', + category: migrateLegacyProductCategory(req.product.category), + ownerId: ctx.user.id, + hotspots: [], + hotspotsSuggested: [], + aiAnalysis: null, + inputMethod: req.manualModelAsset ? 'manual_glb' : 'manual_upload', + createdAt: now, + updatedAt: now, + }), + ); + + return createConversionForProduct(ctx, product, req); +} + +async function handleTryImportedProduct3d(ctx: RequestContext, productId: string): Promise { + const product = await getOwnedProduct(ctx, productId); + const sourceAssets = getImportedSourceAssets(product); + if (sourceAssets.length === 0) throw new Error('Invalid request'); + + return createConversionForProduct(ctx, product, { + sourceAssets: sourceAssets.map((asset) => ({ + url: asset.url, + storageKey: asset.storageKey, + mimeType: asset.mimeType, + sizeBytes: asset.sizeBytes, + })), + }); +} + async function handleGetConversion(ctx: RequestContext, conversionId: string): Promise { const conversion = await getOwnedConversion(ctx, conversionId); return { conversion: toConversionSnapshot(conversion) }; @@ -563,6 +866,16 @@ async function handleApproveConversion(ctx: RequestContext, conversionId: string const approved = await conversionRepo.save(conversion.approve(ctx.user.id)); await eventsRepo.track(approved.productId, ctx.user.id, 'conversion_approved'); await eventsRepo.track(approved.productId, ctx.user.id, 'product_published'); + + // Phase I: Record approval signal for feedback loop + await new GenerationFeedbackService(ctx.admin, ctx.user.id) + .recordApproval( + approved.productId, + approved.id, + undefined, + typeof approved.qualityReport?.geminiQaScore === 'number' ? approved.qualityReport.geminiQaScore : undefined, + ).catch(() => { /* non-fatal */ }); + return { conversion: toConversionSnapshot(approved) }; } @@ -576,6 +889,15 @@ async function handleRejectConversion( const conversion = await getOwnedConversion(ctx, conversionId); const rejected = await conversionRepo.save(conversion.reject(req.reason)); await eventsRepo.track(rejected.productId, ctx.user.id, 'conversion_rejected', { reason: req.reason }); + + // Phase I: Record rejection signal for feedback loop + await new GenerationFeedbackService(ctx.admin, ctx.user.id) + .recordRejection( + rejected.productId, + rejected.id, + req.reason ?? 'no reason given', + ).catch(() => { /* non-fatal */ }); + return { conversion: toConversionSnapshot(rejected) }; } @@ -584,8 +906,9 @@ async function handleAnalyzeProduct(ctx: RequestContext, req: AnalyzeProductRequ const eventsRepo = new SupabaseEventsRepository(ctx.admin); const product = await getOwnedProduct(ctx, req.productId); const conversion = await getLatestConversionForProduct(ctx, product.id); + const sourceAssets = getImportedSourceAssets(product); await eventsRepo.track(product.id, ctx.user.id, 'ai_analysis_started'); - const analysis = await analyzeProductWithGemini(ctx, product, conversion?.sourceAssets[0]); + const analysis = await analyzeProductWithGemini(ctx, product, sourceAssets.length > 0 ? sourceAssets : [...(conversion?.sourceAssets ?? [])]); const saved = await productRepo.save(product.withAiAnalysis(analysis)); await eventsRepo.track(product.id, ctx.user.id, 'ai_analysis_completed'); return { analysis: saved.aiAnalysis ?? analysis }; @@ -598,7 +921,8 @@ async function handleGenerateHotspots( const productRepo = new SupabaseProductRepository(ctx.admin); const product = await getOwnedProduct(ctx, req.productId); const conversion = await getLatestConversionForProduct(ctx, product.id); - const hotspots = await generateSuggestedHotspots(ctx, product, conversion?.sourceAssets[0]); + const sourceAssets = getImportedSourceAssets(product); + const hotspots = await generateSuggestedHotspots(ctx, product, sourceAssets.length > 0 ? sourceAssets : [...(conversion?.sourceAssets ?? [])]); const saved = await productRepo.save(product.withSuggestedHotspots(hotspots)); return { hotspots: saved.hotspotsSuggested }; } @@ -626,6 +950,7 @@ async function handleQualityCheck(ctx: RequestContext, req: QualityCheckRequest) const productRepo = new SupabaseProductRepository(ctx.admin); const product = await getOwnedProduct(ctx, req.productId); const conversion = await getLatestConversionForProduct(ctx, product.id); + const importReadiness = importedReadiness(product); let qaRecommendations: string[] = conversion?.qualityReport?.geminiQaReport?.recommendedActions ?? []; @@ -667,15 +992,19 @@ async function handleQualityCheck(ctx: RequestContext, req: QualityCheckRequest) ...(conversion?.sourceAssets.length && conversion.sourceAssets.length < 3 ? ['Add more source angles before publishing to improve 3D fidelity.'] : []), + ...(importReadiness && !importReadiness.hasEnoughUniqueViews + ? ['Add more unique imported product angles before publishing.'] + : []), ...(conversion?.qualityReport?.warnings ?? []), ...qaRecommendations, ]; - const readinessScore = conversion?.qualityReport?.score() ?? product.aiAnalysis?.readinessScore; + const readinessScore = conversion?.qualityReport?.score() ?? importReadiness?.score ?? product.aiAnalysis?.readinessScore; await productRepo.save( product.withAiAnalysis( mergeAnalysis(product, { readinessScore, + sourceImageEntries: importReadiness?.entries ?? product.aiAnalysis?.sourceImageEntries, qualityRecommendations: recommendations, }), ), @@ -830,6 +1159,38 @@ export function createApiServer(env = getEnv()) { return; } + if (req.method === 'POST' && pathname === '/api/products/import-url') { + const body = await readJson(req); + sendJson(res, 200, await handleImportProductUrl(ctx, body), env.corsOrigin); + return; + } + + const importReviewMatch = pathname.match(/^\/api\/products\/([^/]+)\/import\/review$/); + if (req.method === 'POST' && importReviewMatch) { + const body = await readJson(req); + sendJson(res, 200, await handleSaveImportedReview(ctx, importReviewMatch[1], body), env.corsOrigin); + return; + } + + const importRetryMatch = pathname.match(/^\/api\/products\/([^/]+)\/import\/retry$/); + if (req.method === 'POST' && importRetryMatch) { + sendJson(res, 200, await handleRetryImportedProduct(ctx, importRetryMatch[1]), env.corsOrigin); + return; + } + + const acceptClusterMatch = pathname.match(/^\/api\/products\/([^/]+)\/import\/accept-cluster$/); + if (req.method === 'POST' && acceptClusterMatch) { + const body = await readJson(req); + sendJson(res, 200, await handleAcceptProductCluster(ctx, acceptClusterMatch[1], body), env.corsOrigin); + return; + } + + const import3dMatch = pathname.match(/^\/api\/products\/([^/]+)\/try-3d$/); + if (req.method === 'POST' && import3dMatch) { + sendJson(res, 200, await handleTryImportedProduct3d(ctx, import3dMatch[1]), env.corsOrigin); + return; + } + const conversionMatch = pathname.match(/^\/api\/conversions\/([^/]+)$/); if (req.method === 'GET' && conversionMatch) { sendJson(res, 200, await handleGetConversion(ctx, conversionMatch[1]), env.corsOrigin); diff --git a/apps/web/package.json b/apps/web/package.json index 02184ad..35eb691 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,6 +3,10 @@ "version": "0.0.1", "private": true, "dependencies": { + "@minimalblock/core": "workspace:*", + "@minimalblock/data": "workspace:*", + "@minimalblock/features": "workspace:*", + "@minimalblock/ui": "workspace:*", "i18next": "^26.2.0", "react-i18next": "^17.0.8" } diff --git a/apps/web/src/app/AppChrome.tsx b/apps/web/src/app/AppChrome.tsx index f12faa1..8713241 100644 --- a/apps/web/src/app/AppChrome.tsx +++ b/apps/web/src/app/AppChrome.tsx @@ -59,27 +59,30 @@ function pageMetaForPathname(pathname: string): { } { if (pathname.startsWith('/upload')) { return { - pageTitle: '3D Model Oluştur', - breadcrumbs: [], + pageTitle: 'Upload for QA Review', + breadcrumbs: [ + { id: 'qa', label: 'Product QA', href: '/' }, + { id: 'upload', label: 'Upload' }, + ], }; } if (pathname.startsWith('/product/')) { return { - pageTitle: 'Ürün Detayı', + pageTitle: 'QA Review', breadcrumbs: [ - { id: 'catalog', label: 'Katalog', href: '/' }, - { id: 'gallery', label: 'Galeri', href: '/' }, - { id: 'detail', label: 'Ürün Detayı' }, + { id: 'qa', label: 'Product QA', href: '/' }, + { id: 'queue', label: 'QA Queue', href: '/' }, + { id: 'detail', label: 'QA Review' }, ], }; } return { - pageTitle: 'Galeri', + pageTitle: 'QA Queue', breadcrumbs: [ - { id: 'catalog', label: 'Katalog', href: '/' }, - { id: 'gallery', label: 'Galeri' }, + { id: 'qa', label: 'Product QA', href: '/' }, + { id: 'queue', label: 'QA Queue' }, ], }; } @@ -97,17 +100,17 @@ export function AppChrome({ children, onSignOut }: AppChromeProps) { const [language, setLanguage] = useState('tr'); const [notifications, setNotifications] = useState([ { - id: 'gallery-warning', - title: 'İki fotoğraf yükleme daha yüksek çözünürlük gerektiriyor', - body: '1800px altındaki görseller zayıf 3D geometriye yol açabilir.', - ts: '28d önce', + id: 'qa-blocked', + title: 'Product blocked — QA failed', + body: 'Laptop model scored 18/100. Publish gated until issues are resolved.', + ts: '2h ago', read: false, }, { - id: 'sync-complete', - title: 'Katalog senkronizasyonu tamamlandı', - body: '284 ürün mağaza ön yüzü ve yönetim panelinde hizalandı.', - ts: 'Dün', + id: 'qa-approved', + title: 'Product approved — ready to publish', + body: 'Chair model passed QA with 87/100. Export package is ready.', + ts: 'Yesterday', read: true, }, ]); @@ -117,9 +120,9 @@ export function AppChrome({ children, onSignOut }: AppChromeProps) { id: 'main', label: '', items: [ - { kind: 'link' as const, id: 'home', label: 'Ana sayfa', href: '/', icon: 'home' as const }, - { kind: 'link' as const, id: 'create-3d', label: '3D Oluştur', href: '/upload', icon: 'bolt' as const }, - { kind: 'link' as const, id: 'brand', label: 'Marka Kimliği', href: '/brand', icon: 'tag' as const }, + { kind: 'link' as const, id: 'home', label: 'QA Queue', href: '/', icon: 'home' as const }, + { kind: 'link' as const, id: 'upload', label: 'Upload Product', href: '/upload', icon: 'bolt' as const }, + { kind: 'link' as const, id: 'brand', label: 'Brand Settings', href: '/brand', icon: 'tag' as const }, ], }, ]; @@ -152,7 +155,7 @@ export function AppChrome({ children, onSignOut }: AppChromeProps) { isCurrentPath(location.pathname, href)} - brand={{ name: 'Minimal Block', tagline: 'Commerce control center' }} + brand={{ name: 'Minimal Block', tagline: 'AI Visual Commerce QA' }} store={store} currency={{ id: 'currency', ariaLabel: 'Currency', value: 'TRY', options: [] }} language={languageSelector} diff --git a/apps/web/src/components/import/MultiProductClusterSelector.tsx b/apps/web/src/components/import/MultiProductClusterSelector.tsx new file mode 100644 index 0000000..6ae3bea --- /dev/null +++ b/apps/web/src/components/import/MultiProductClusterSelector.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import type { ImportedImageCandidate, ProductCluster } from '@minimalblock/core'; + +interface Props { + clusters: ProductCluster[]; + imageCandidates: ImportedImageCandidate[]; + onAccept: (clusterId: string) => Promise; +} + +const CONFIDENCE_COLORS = { + high: 'bg-green-100 text-green-700', + medium: 'bg-yellow-100 text-yellow-700', + low: 'bg-orange-100 text-orange-700', +} as const; + +export function MultiProductClusterSelector({ clusters, imageCandidates, onAccept }: Props) { + const [accepting, setAccepting] = useState(null); + + const handleAccept = async (clusterId: string) => { + setAccepting(clusterId); + try { + await onAccept(clusterId); + } finally { + setAccepting(null); + } + }; + + return ( +
+
+ +
+

Multiple products detected on this page

+

Select which product you want to import. Each will become a separate listing.

+
+
+ +
+ {clusters.map((cluster) => { + const clusterImages = cluster.imageIds + .map((id) => imageCandidates.find((c) => c.id === id)) + .filter((c): c is ImportedImageCandidate => c !== undefined && !c.aiRejected); + + return ( +
+
+ {cluster.clusterLabel} + + {cluster.confidence} + +
+ + {clusterImages.length > 0 && ( +
+ {clusterImages.slice(0, 4).map((img) => ( +
+ {img.url ? ( + {img.alt + ) : ( +
No img
+ )} +
+ ))} + {clusterImages.length > 4 && ( +
+ +{clusterImages.length - 4} +
+ )} +
+ )} + +
+ {cluster.fields.category?.value && ( +

Category: {cluster.fields.category.value}

+ )} + {cluster.fields.materials?.value?.length ? ( +

Materials: {cluster.fields.materials.value.join(', ')}

+ ) : null} + {cluster.fields.dimensions?.value && ( +

Dimensions: {cluster.fields.dimensions.value}

+ )} + {cluster.materialFinish && cluster.materialFinish !== 'unknown' && ( +

Finish: {cluster.materialFinish}

+ )} +
+ + +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 26367b2..fef66cb 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1,9 +1,9 @@ export const en = { nav: { workspace: '', - catalog: '3D Model', - gallery: '3D Gallery', - uploadAssets: 'Generate 3D Model', + catalog: 'Product QA', + gallery: 'QA Gallery', + uploadAssets: 'Upload Product', analytics: 'Analytics', orders: 'Orders', storeStatus: 'Store status', @@ -45,44 +45,49 @@ export const en = { other: 'Other', }, gallery: { - loading: 'Loading gallery…', - catalog: 'Catalog', - title: 'Gallery', + loading: 'Loading product QA queue…', + catalog: 'Product QA', + title: 'QA Review Queue', description: - 'Generate and manage 3D product models from product images. Keep uploads, generation status, and review work in one place.', - uploadPhoto: 'Upload product photo', - viewRequirements: 'View upload requirements', + 'Upload products, run AI quality checks, and approve assets before they reach buyers. Blocked assets never reach your storefront.', + uploadPhoto: 'Upload product', + viewRequirements: 'View QA requirements', noModelsYet: { - title: 'No 3D models yet', + title: 'No products in QA yet', description: - 'Upload a product photo to create your first model. Once the image passes review, Minimal Block will generate a reusable 3D asset for your gallery and storefront.', + 'Upload a product with images or a GLB model. The AI quality engine will score it, flag issues, and surface a clear next action before anything goes live.', }, noMatch: { - title: 'No models match this filter', + title: 'No products match this filter', description: - 'Try a different status or sort option to find the models you need.', + 'Try a different status or sort option to find the products you need.', }, - loadMore: 'Load more models', + loadMore: 'Load more products', delete: 'Delete', uncategorized: 'Uncategorized', - ready3d: '3D ready', + ready3d: 'QA passed', + qaScore: 'QA score: {{score}}/100', + readinessLabel: 'Marketplace readiness', + readinessReady: 'Ready to publish', + readinessBlocked: 'Blocked — QA failed', + readinessPending: 'Awaiting review', hotspotsCount_one: '{{count}} hotspot configured', hotspotsCount_other: '{{count}} hotspots configured', noHotspots: 'No hotspots configured yet', - openDetails: 'Open details', + openDetails: 'Review product', deleteModal: { title: 'Delete product', description: - 'This permanently deletes the product, its 3D model, and all interaction history.', + 'This permanently deletes the product, its 3D model, and all QA history.', }, emptyActions: { - uploadPhoto: 'Upload product photo', - learn3d: 'Learn about 3D generation', + uploadPhoto: 'Upload your first product', + learn3d: 'How QA scoring works', }, requirements: { - title: 'Upload requirements', + title: 'QA upload requirements', description: - 'Strong source images lead to cleaner geometry and fewer failed generations.', + 'High-quality source assets lead to accurate AI scores and fewer blocked products.', clearPhoto: 'Use a clear product photo', clearPhotoDesc: 'Avoid blur, motion, and heavy compression artifacts.', plainBackground: 'Use a plain background', @@ -90,16 +95,16 @@ export const en = { 'A simple backdrop improves edge detection and masking.', jpgOrPng: 'Upload JPG or PNG', jpgOrPngDesc: - 'Standard raster formats keep the upload and conversion pipeline predictable.', + 'Standard raster formats keep the upload and QA pipeline predictable.', resolution: 'Recommended minimum: 1800 × 1800', resolutionDesc: - 'Higher resolution gives the model generator more surface detail to work with.', + 'Higher resolution gives the QA engine more surface detail to evaluate.', }, toolbar: { status: 'Status', sort: 'Sort', - allModels: 'All models', - ready: 'Ready', + allModels: 'All products', + ready: 'Approved', processing: 'Processing', needsReview: 'Needs review', newestFirst: 'Newest first', @@ -199,32 +204,48 @@ export const en = { actionInvoice: 'Invoice', }, product: { - loading: 'Loading product review…', - notFound: 'Model not found', - backGallery: '← Gallery', - download: 'Download', - embed: 'Embed', - publishTrendyol: 'Publish to Trendyol', + loading: 'Loading QA review…', + notFound: 'Product not found', + backGallery: '← QA Queue', + download: 'Download GLB', + embed: 'Embed preview', + publishTrendyol: 'Export to Trendyol', + sharePublicPage: 'Share public page', editProduct: 'Edit product', saveHotspots: 'Save hotspots', addHotspot: 'Add hotspot', editHotspots: 'Edit hotspots', - approve: 'Approve', - reject: 'Reject', + approve: 'Approve for publish', + reject: 'Block — needs fix', deleteProduct: 'Delete product', + qaScoreLabel: 'QA Score', + marketplaceReadiness: 'Marketplace readiness', + readinessApproved: 'Ready to publish', + readinessBlocked: 'Blocked — fix required', + readinessPending: 'Awaiting seller review', + exportPackage: 'Export package', + exportPackageReady: 'Package ready', + exportPackageBlocked: 'Package blocked — QA not passed', + exportPackagePending: 'Package pending approval', + failureBannerTitle: 'This product is blocked from publishing', + failureBannerBody: 'The AI QA check found issues that must be resolved before this asset can be listed or shared with buyers.', + approvalBannerTitle: 'Awaiting seller review', + approvalBannerBody: 'Review the AI diagnosis below, then approve to unlock publishing or block to request a fix.', + approvedBannerTitle: 'Approved — ready to publish', + approvedBannerBody: 'This product passed QA and can now be listed on marketplaces or shared with buyers.', deleteModal: { description: - 'This will permanently delete the product, its 3D model, and all interaction history. This cannot be undone.', + 'This will permanently delete the product, its 3D model, and all QA history. This cannot be undone.', }, rejectModal: { - title: 'Reject conversion', - placeholder: 'e.g. geometry distortion around the handle', - confirmBtn: 'Reject conversion', + title: 'Block product — needs fix', + placeholder: 'e.g. geometry distortion around the handle, missing back panel', + confirmBtn: 'Block — needs fix', }, embedModal: { title: 'Share / Embed', description: - 'Paste one of these snippets into any webpage to show the interactive 3D viewer.', + 'Paste one of these snippets into any webpage to show the interactive 3D preview. Only available for approved products.', ready: 'Ready to copy', copied: 'Copied to clipboard', copyBtn: 'Copy snippet', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index 185f19a..dfcfc24 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -1,9 +1,9 @@ export const tr = { nav: { workspace: '', - catalog: '3D Model', - gallery: '3D Galeri', - uploadAssets: '3D Model Oluştur', + catalog: 'Ürün KG', + gallery: 'KG Kuyruğu', + uploadAssets: 'Ürün Yükle', analytics: 'Analitik', orders: 'Siparişler', storeStatus: 'Mağaza durumu', @@ -45,44 +45,49 @@ export const tr = { other: 'Diğer', }, gallery: { - loading: 'Galeri yükleniyor…', - catalog: 'Katalog', - title: 'Galeri', + loading: 'Ürün KG kuyruğu yükleniyor…', + catalog: 'Ürün KG', + title: 'KG İnceleme Kuyruğu', description: - 'Ürün görsellerinden 3D ürün modelleri oluşturun ve yönetin. Yüklemeleri, oluşturma durumunu ve inceleme sürecini tek yerden takip edin.', - uploadPhoto: 'Ürün fotoğrafı yükle', - viewRequirements: 'Yükleme gereksinimlerini görüntüle', + 'Ürün yükleyin, yapay zeka kalite kontrolü çalıştırın ve alıcılara ulaşmadan önce varlıkları onaylayın. Engellenen varlıklar asla mağazanıza ulaşmaz.', + uploadPhoto: 'Ürün yükle', + viewRequirements: 'KG gereksinimlerini görüntüle', noModelsYet: { - title: 'Henüz 3D model yok', + title: 'Henüz KG kuyruğunda ürün yok', description: - 'İlk modelinizi oluşturmak için bir ürün fotoğrafı yükleyin. Görsel incelemeden geçtikten sonra Minimal Block, galeri ve mağazanız için yeniden kullanılabilir bir 3D varlık oluşturacaktır.', + 'Görsel veya GLB model ile bir ürün yükleyin. Yapay zeka kalite motoru puanlayacak, sorunları işaretleyecek ve herhangi bir şey yayına girmeden önce net bir sonraki adım sunacak.', }, noMatch: { - title: 'Bu filtreyle eşleşen model yok', + title: 'Bu filtreyle eşleşen ürün yok', description: - 'İhtiyacınız olan modelleri bulmak için farklı bir durum veya sıralama seçeneği deneyin.', + 'İhtiyacınız olan ürünleri bulmak için farklı bir durum veya sıralama seçeneği deneyin.', }, - loadMore: 'Daha fazla model yükle', + loadMore: 'Daha fazla ürün yükle', delete: 'Sil', uncategorized: 'Kategorisiz', - ready3d: '3D hazır', + ready3d: 'KG geçti', + qaScore: 'KG puanı: {{score}}/100', + readinessLabel: 'Pazar yeri hazırlığı', + readinessReady: 'Yayınlamaya hazır', + readinessBlocked: 'Engellendi — KG başarısız', + readinessPending: 'İnceleme bekliyor', hotspotsCount_one: '{{count}} hotspot yapılandırıldı', hotspotsCount_other: '{{count}} hotspot yapılandırıldı', noHotspots: 'Henüz hotspot yapılandırılmadı', - openDetails: 'Detayları aç', + openDetails: 'Ürünü incele', deleteModal: { title: 'Ürünü sil', description: - 'Bu işlem ürünü, 3D modelini ve tüm etkileşim geçmişini kalıcı olarak siler.', + 'Bu işlem ürünü, 3D modelini ve tüm KG geçmişini kalıcı olarak siler.', }, emptyActions: { - uploadPhoto: 'Ürün fotoğrafı yükle', - learn3d: '3D oluşturma hakkında bilgi al', + uploadPhoto: 'İlk ürününüzü yükleyin', + learn3d: 'KG puanlama nasıl çalışır', }, requirements: { - title: 'Yükleme gereksinimleri', + title: 'KG yükleme gereksinimleri', description: - 'Güçlü kaynak görseller daha temiz geometri ve daha az başarısız oluşturma sağlar.', + 'Yüksek kaliteli kaynak varlıklar daha doğru yapay zeka puanlarına ve daha az engellenen ürüne yol açar.', clearPhoto: 'Net bir ürün fotoğrafı kullanın', clearPhotoDesc: 'Bulanıklıktan, hareketten ve ağır sıkıştırma eserlerinden kaçının.', @@ -91,16 +96,16 @@ export const tr = { 'Basit bir zemin kenar algılama ve maskelemeyi iyileştirir.', jpgOrPng: 'JPG veya PNG yükleyin', jpgOrPngDesc: - 'Standart raster formatlar yükleme ve dönüştürme sürecini öngörülebilir tutar.', + 'Standart raster formatlar yükleme ve KG sürecini öngörülebilir tutar.', resolution: 'Önerilen minimum: 1800 × 1800', resolutionDesc: - 'Daha yüksek çözünürlük, model oluşturucuya daha fazla yüzey detayı sağlar.', + 'Daha yüksek çözünürlük, KG motoruna değerlendirmek için daha fazla yüzey detayı sağlar.', }, toolbar: { status: 'Durum', sort: 'Sırala', - allModels: 'Tüm modeller', - ready: 'Hazır', + allModels: 'Tüm ürünler', + ready: 'Onaylandı', processing: 'İşleniyor', needsReview: 'İnceleme gerekiyor', newestFirst: 'En yeni önce', @@ -201,32 +206,48 @@ export const tr = { actionInvoice: 'Faturalandır', }, product: { - loading: 'Ürün incelemesi yükleniyor…', - notFound: 'Model bulunamadı', - backGallery: '← Galeri', - download: 'İndir', - embed: 'Göm', - publishTrendyol: "Trendyol'da yayınla", + loading: 'KG incelemesi yükleniyor…', + notFound: 'Ürün bulunamadı', + backGallery: '← KG Kuyruğu', + download: 'GLB İndir', + embed: 'Önizlemeyi göm', + publishTrendyol: "Trendyol'a dışa aktar", + sharePublicPage: 'Genel sayfayı paylaş', editProduct: 'Ürünü düzenle', saveHotspots: "Hotspot'ları kaydet", addHotspot: 'Hotspot ekle', editHotspots: "Hotspot'ları düzenle", - approve: 'Onayla', - reject: 'Reddet', + approve: 'Yayın için onayla', + reject: 'Engelle — düzeltme gerekiyor', deleteProduct: 'Ürünü sil', + qaScoreLabel: 'KG Puanı', + marketplaceReadiness: 'Pazar yeri hazırlığı', + readinessApproved: 'Yayınlamaya hazır', + readinessBlocked: 'Engellendi — düzeltme gerekiyor', + readinessPending: 'Satıcı incelemesi bekliyor', + exportPackage: 'Dışa aktarma paketi', + exportPackageReady: 'Paket hazır', + exportPackageBlocked: 'Paket engellendi — KG geçmedi', + exportPackagePending: 'Paket onay bekliyor', + failureBannerTitle: 'Bu ürün yayından engellendi', + failureBannerBody: 'Yapay zeka KG kontrolü, bu varlık listelenmeden veya alıcılarla paylaşılmadan önce çözülmesi gereken sorunlar buldu.', + approvalBannerTitle: 'Satıcı incelemesi bekliyor', + approvalBannerBody: 'Aşağıdaki yapay zeka tanısını inceleyin, ardından yayını açmak için onaylayın veya düzeltme istemek için engelleyin.', + approvedBannerTitle: 'Onaylandı — yayınlamaya hazır', + approvedBannerBody: 'Bu ürün KG\'den geçti ve artık pazar yerlerinde listelenebilir veya alıcılarla paylaşılabilir.', deleteModal: { description: - 'Bu işlem ürünü, 3D modelini ve tüm etkileşim geçmişini kalıcı olarak siler. Bu işlem geri alınamaz.', + 'Bu işlem ürünü, 3D modelini ve tüm KG geçmişini kalıcı olarak siler. Bu işlem geri alınamaz.', }, rejectModal: { - title: 'Dönüşümü reddet', - placeholder: 'örn. tutamaç etrafında geometri bozulması', - confirmBtn: 'Dönüşümü reddet', + title: 'Ürünü engelle — düzeltme gerekiyor', + placeholder: 'örn. tutamaç etrafında geometri bozulması, arka panel eksik', + confirmBtn: 'Engelle — düzeltme gerekiyor', }, embedModal: { title: 'Paylaş / Göm', description: - 'İnteraktif 3D görüntüleyiciyi göstermek için bu parçacıklardan birini herhangi bir web sayfasına yapıştırın.', + 'İnteraktif 3D önizlemeyi göstermek için bu parçacıklardan birini herhangi bir web sayfasına yapıştırın. Yalnızca onaylanan ürünler için kullanılabilir.', ready: 'Kopyalamaya hazır', copied: 'Panoya kopyalandı', copyBtn: 'Parçacığı kopyala', diff --git a/apps/web/src/lib/merchant-api-client.ts b/apps/web/src/lib/merchant-api-client.ts index 15268a3..56364b6 100644 --- a/apps/web/src/lib/merchant-api-client.ts +++ b/apps/web/src/lib/merchant-api-client.ts @@ -1,15 +1,21 @@ import type { + AcceptProductClusterRequest, + AcceptProductClusterResponse, AnalyzeProductResponse, ConversionResponse, CreateConversionRequest, CreateConversionResponse, GenerateDescriptionResponse, GenerateHotspotsResponse, + ImportProductUrlRequest, + ImportProductUrlResponse, QualityCheckResponse, RejectConversionRequest, + RetryImportedProductResponse, ReturnRiskResponse, + SaveImportedReviewRequest, + SaveImportedReviewResponse, } from '@minimalblock/core'; -import type { SupabaseClient } from '@supabase/supabase-js'; export interface TrendyolProductDraft { title: string; @@ -57,7 +63,11 @@ export interface BatchResult { export class MerchantApiClient { constructor( private readonly baseUrl: string, - private readonly supabase: SupabaseClient, + private readonly supabase: { + auth: { + getSession(): Promise<{ data: { session: { access_token?: string } | null } }>; + }; + }, ) {} private async request(path: string, init?: RequestInit): Promise { @@ -90,6 +100,41 @@ export class MerchantApiClient { }); } + tryImportedProduct3d(productId: string): Promise { + return this.request(`/api/products/${productId}/try-3d`, { + method: 'POST', + body: JSON.stringify({}), + }); + } + + importProductUrl(input: ImportProductUrlRequest): Promise { + return this.request('/api/products/import-url', { + method: 'POST', + body: JSON.stringify(input), + }); + } + + saveImportedReview(productId: string, input: SaveImportedReviewRequest): Promise { + return this.request(`/api/products/${productId}/import/review`, { + method: 'POST', + body: JSON.stringify(input), + }); + } + + retryImportedProduct(productId: string): Promise { + return this.request(`/api/products/${productId}/import/retry`, { + method: 'POST', + body: JSON.stringify({}), + }); + } + + acceptProductCluster(productId: string, input: AcceptProductClusterRequest): Promise { + return this.request(`/api/products/${productId}/import/accept-cluster`, { + method: 'POST', + body: JSON.stringify(input), + }); + } + getConversion(conversionId: string): Promise { return this.request(`/api/conversions/${conversionId}`); } diff --git a/apps/web/src/pages/GalleryPage.tsx b/apps/web/src/pages/GalleryPage.tsx index d03938a..c6cedaf 100644 --- a/apps/web/src/pages/GalleryPage.tsx +++ b/apps/web/src/pages/GalleryPage.tsx @@ -29,11 +29,17 @@ interface GalleryPageProps { const PAGE_SIZE = 12; function mapStatus(status: string): 'ready' | 'processing' | 'failed' { - if (status === 'completed' || status === 'approved') return 'ready'; + if (status === 'approved') return 'ready'; if (status === 'failed' || status === 'rejected') return 'failed'; return 'processing'; } +function readinessLabel(status: string, t: (key: string) => string): { label: string; color: string } { + if (status === 'approved') return { label: t('gallery.readinessReady'), color: 'text-emerald-700 bg-emerald-50' }; + if (status === 'failed' || status === 'rejected') return { label: t('gallery.readinessBlocked'), color: 'text-red-700 bg-red-50' }; + return { label: t('gallery.readinessPending'), color: 'text-amber-700 bg-amber-50' }; +} + function toGalleryModel( conversion: UseGalleryState['conversions'][number], product?: Product, @@ -52,6 +58,30 @@ function toGalleryModel( modelUrl, hotspotCount: product?.hotspots.length ?? 0, errorMessage: conversion.errorMessage, + qaScore: conversion.qualityReport?.score(), + }; +} + +function toImportedGalleryModel(product: Product): GalleryModel { + const selectedPreview = product.importData?.imageCandidates.find((candidate) => candidate.selected && candidate.url)?.url + ?? product.importData?.imageCandidates.find((candidate) => candidate.url)?.url; + const syntheticStatus = + product.workflowStatus === 'approved' || product.workflowStatus === 'published' + ? 'approved' + : product.workflowStatus === 'scrape_failed' || product.workflowStatus === 'failed_qa' + ? 'failed' + : 'processing'; + + return { + id: product.id, + productId: product.id, + name: product.name, + category: product.category, + status: syntheticStatus, + previewUrl: selectedPreview, + hotspotCount: product.hotspots.length, + errorMessage: product.importData?.failureReasons.join(', ') || undefined, + qaScore: product.aiAnalysis?.readinessScore, }; } @@ -80,7 +110,14 @@ export function GalleryPage({ user }: GalleryPageProps) { }, [productRepo, user.id, conversions]); const galleryModels = useMemo( - () => conversions.map((conversion) => toGalleryModel(conversion, products.get(conversion.productId))), + () => { + const fromConversions = conversions.map((conversion) => toGalleryModel(conversion, products.get(conversion.productId))); + const convertedProductIds = new Set(conversions.map((conversion) => conversion.productId)); + const importOnlyProducts = Array.from(products.values()) + .filter((product) => !convertedProductIds.has(product.id) && product.inputMethod === 'url_import') + .map((product) => toImportedGalleryModel(product)); + return [...importOnlyProducts, ...fromConversions]; + }, [conversions, products], ); @@ -204,12 +241,16 @@ export function GalleryPage({ user }: GalleryPageProps) { } > {visibleModels.map((model) => { - const isReady = mapStatus(model.status) === 'ready' && !!model.modelUrl; + const isApproved = model.status === 'approved'; + const isFailed = model.status === 'failed' || model.status === 'rejected'; + const isReady = isApproved && !!model.modelUrl; + const readiness = readinessLabel(model.status, t); return (
@@ -223,7 +264,8 @@ export function GalleryPage({ user }: GalleryPageProps) { >
@@ -239,12 +281,13 @@ export function GalleryPage({ user }: GalleryPageProps) { {model.category ?? t('gallery.uncategorized')} - {isReady && ( - - {t('gallery.ready3d')} - - )}
+ + {isFailed && ( +
+ Blocked — publish gated +
+ )}
@@ -262,8 +305,25 @@ export function GalleryPage({ user }: GalleryPageProps) {
+ {/* QA Score row */} +
+ {model.qaScore !== undefined && ( + = 70 ? 'bg-emerald-50 text-emerald-700' : + model.qaScore >= 40 ? 'bg-amber-50 text-amber-700' : + 'bg-red-50 text-red-700') + }> + {t('gallery.qaScore', { score: model.qaScore })} + + )} + + {readiness.label} + +
+ {model.errorMessage && ( -

{model.errorMessage}

+

{model.errorMessage}

)}
diff --git a/apps/web/src/pages/ProductDetailPage.tsx b/apps/web/src/pages/ProductDetailPage.tsx index 19aeb21..b33f3a6 100644 --- a/apps/web/src/pages/ProductDetailPage.tsx +++ b/apps/web/src/pages/ProductDetailPage.tsx @@ -5,16 +5,22 @@ import { useNavigate, useParams } from 'react-router-dom'; import { Conversion, ConversionStatus, + HotspotQuality, + type ImportedImageCandidate, MediaAsset, PRODUCT_CATEGORIES, + ProductWorkflowStatus, QualityReport, + SourceImageReadiness, generateId, type ConversionSnapshot, type Hotspot, type Product, type ProductCategory, + type ProductCluster, } from '@minimalblock/core'; -import { ModelViewer, ModelViewerPlaceholder, StatusBadge, Button, Spinner, Card, Modal } from '@minimalblock/ui'; +import { MultiProductClusterSelector } from '../components/import/MultiProductClusterSelector.js'; +import { ModelViewer, ModelViewerPlaceholder, ModelInfoCard, StatusBadge, WorkflowStatusBadge, Button, Spinner, Card, Modal, AiDiagnosisPanel, SourceImageReadinessCard, HotspotEditorPanel, type ModelViewerHandle } from '@minimalblock/ui'; import { useApp } from '../context/AppContext.js'; import type { SupabaseUser } from '../types.js'; @@ -33,12 +39,12 @@ function hydrateConversion(snapshot: ConversionSnapshot): Conversion { })); const outputAsset = snapshot.outputAsset ? new MediaAsset({ - url: snapshot.outputAsset.url, - storageKey: snapshot.outputAsset.storageKey, - mimeType: snapshot.outputAsset.mimeType, - kind: 'generated-model', - sizeBytes: snapshot.outputAsset.sizeBytes, - }) + url: snapshot.outputAsset.url, + storageKey: snapshot.outputAsset.storageKey, + mimeType: snapshot.outputAsset.mimeType, + kind: 'generated-model', + sizeBytes: snapshot.outputAsset.sizeBytes, + }) : undefined; return new Conversion({ @@ -52,6 +58,7 @@ function hydrateConversion(snapshot: ConversionSnapshot): Conversion { errorMessage: snapshot.errorMessage, provider: snapshot.provider, qualityReport: snapshot.qualityReport ? QualityReport.fromJSON(snapshot.qualityReport) : undefined, + modelSource: snapshot.modelSource ?? 'ai-generated', approvedAt: snapshot.approvedAt ? new Date(snapshot.approvedAt) : undefined, rejectionReason: snapshot.rejectionReason, createdAt: new Date(snapshot.createdAt), @@ -79,6 +86,30 @@ function buildModelViewerSnippet(modelUrl: string): string { return `\n`; } +function toImportedMediaAssets(candidates: ImportedImageCandidate[] | undefined): MediaAsset[] { + return (candidates ?? []) + .filter((candidate) => candidate.selected && candidate.url && candidate.storageKey && candidate.mimeType && candidate.sizeBytes !== undefined) + .map((candidate) => new MediaAsset({ + url: candidate.url!, + storageKey: candidate.storageKey!, + mimeType: candidate.mimeType!, + kind: 'source-image', + sizeBytes: candidate.sizeBytes!, + })); +} + +function supportLevelLabel(level: string | undefined): string { + switch (level) { + case 'supported': + return 'Supported domain'; + case 'mock': + return 'Mock demo import'; + case 'best_effort': + default: + return 'Best-effort extraction'; + } +} + export function ProductDetailPage({ user }: ProductDetailPageProps) { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -93,6 +124,9 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { const [editingMeta, setEditingMeta] = useState(false); const [metaForm, setMetaForm] = useState({ name: '', description: '', category: 'other' as ProductCategory }); const [savingMeta, setSavingMeta] = useState(false); + const [savingImportReview, setSavingImportReview] = useState(false); + const [retryingImport, setRetryingImport] = useState(false); + const [startingImported3d, setStartingImported3d] = useState(false); const [editMode, setEditMode] = useState(false); const [savingHotspots, setSavingHotspots] = useState(false); const [pendingHotspot, setPendingHotspot] = useState<{ position: string; normal: string } | null>(null); @@ -108,24 +142,49 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { const [rejectReason, setRejectReason] = useState(''); const [busyAction, setBusyAction] = useState(null); const [trendyolOpen, setTrendyolOpen] = useState(false); + const [overrideOpen, setOverrideOpen] = useState(false); + const [overrideReason, setOverrideReason] = useState(''); + const [approvingProduct, setApprovingProduct] = useState(false); + const [diagnosisLoading, setDiagnosisLoading] = useState(false); + const [diagnosisError, setDiagnosisError] = useState(null); + const [importForm, setImportForm] = useState({ + title: '', + description: '', + category: 'other' as ProductCategory, + materials: '', + dimensions: '', + selectedImageIds: [] as string[], + sellerConfirmedText: false, + sellerConfirmedImages: false, + }); const trendyolPublish = useTrendyolPublish(apiClient); const lastRotateEvent = useRef(0); + const modelViewerRef = useRef(null); const loadRecord = useCallback(async () => { if (!id) return; setLoading(true); try { - const found = await conversionRepo.findById(id); - if (!found || !found.isAccessibleBy(user.id)) { - throw new Error('Model not found'); + let foundProduct = await productRepo.findById(id); + let foundConversion: Conversion | null = null; + + if (foundProduct) { + const productConversions = await conversionRepo.findByProductId(foundProduct.id); + foundConversion = [...productConversions].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null; + } else { + const found = await conversionRepo.findById(id); + if (!found || !found.isAccessibleBy(user.id)) { + throw new Error('Model not found'); + } + foundConversion = found; + foundProduct = await productRepo.findById(found.productId); } - const foundProduct = await productRepo.findById(found.productId); if (!foundProduct) { throw new Error('Product not found'); } - setConversion(found); + setConversion(foundConversion); setProduct(foundProduct); setHotspots(foundProduct.hotspots); setMetaForm({ @@ -133,6 +192,16 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { description: foundProduct.description, category: foundProduct.category, }); + setImportForm({ + title: foundProduct.importData?.fields.title?.value ?? foundProduct.name, + description: foundProduct.importData?.fields.description?.value ?? foundProduct.description, + category: foundProduct.importData?.fields.category?.value ?? foundProduct.category, + materials: (foundProduct.importData?.fields.materials?.value ?? foundProduct.aiAnalysis?.materials ?? []).join(', '), + dimensions: foundProduct.importData?.fields.dimensions?.value ?? '', + selectedImageIds: foundProduct.importData?.selectedImageIds ?? [], + sellerConfirmedText: foundProduct.importData?.sellerConfirmedText ?? false, + sellerConfirmedImages: foundProduct.importData?.sellerConfirmedImages ?? false, + }); setError(null); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Failed to load product'); @@ -160,8 +229,6 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { }, [apiClient, conversion]); const productName = product?.name ?? conversion?.sourceAsset.storageKey.split('/').pop() ?? 'Product'; - const publicUrl = product && conversion?.status.isViewable() ? `${window.location.origin}${product.publicUrl}` : null; - const qualityScore = conversion?.qualityReport?.score(); const visibleHotspots = useMemo(() => hotspots.filter((hotspot) => hotspot.position && hotspot.normal), [hotspots]); async function saveMeta() { @@ -176,6 +243,75 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { } } + async function saveImportReview() { + if (!product) return; + setSavingImportReview(true); + setError(null); + try { + const response = await apiClient.saveImportedReview(product.id, { + title: importForm.title.trim(), + description: importForm.description.trim(), + category: importForm.category, + materials: importForm.materials.split(',').map((value) => value.trim()).filter(Boolean), + dimensions: importForm.dimensions.trim(), + selectedImageIds: importForm.selectedImageIds, + sellerConfirmedText: importForm.sellerConfirmedText, + sellerConfirmedImages: importForm.sellerConfirmedImages, + }); + const refreshed = await productRepo.findById(response.product.productId); + if (refreshed) { + setProduct(refreshed); + setHotspots(refreshed.hotspots); + } + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'Failed to save imported review'); + } finally { + setSavingImportReview(false); + } + } + + async function retryImport() { + if (!product) return; + setRetryingImport(true); + setError(null); + try { + const response = await apiClient.retryImportedProduct(product.id); + const refreshed = await productRepo.findById(response.product.productId); + if (refreshed) { + setProduct(refreshed); + setHotspots(refreshed.hotspots); + setImportForm({ + title: refreshed.importData?.fields.title?.value ?? refreshed.name, + description: refreshed.importData?.fields.description?.value ?? refreshed.description, + category: refreshed.importData?.fields.category?.value ?? refreshed.category, + materials: (refreshed.importData?.fields.materials?.value ?? refreshed.aiAnalysis?.materials ?? []).join(', '), + dimensions: refreshed.importData?.fields.dimensions?.value ?? '', + selectedImageIds: refreshed.importData?.selectedImageIds ?? [], + sellerConfirmedText: refreshed.importData?.sellerConfirmedText ?? false, + sellerConfirmedImages: refreshed.importData?.sellerConfirmedImages ?? false, + }); + } + } catch (retryError) { + setError(retryError instanceof Error ? retryError.message : 'Failed to retry import'); + } finally { + setRetryingImport(false); + } + } + + async function tryImported3d() { + if (!product) return; + setStartingImported3d(true); + setError(null); + try { + await apiClient.tryImportedProduct3d(product.id); + await loadRecord(); + } catch (startError) { + setError(startError instanceof Error ? startError.message : 'Failed to start AI 3D generation'); + } finally { + setStartingImported3d(false); + } + } + async function handleDelete() { if (!product) return; setDeleting(true); @@ -210,6 +346,14 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { setHotspots((current) => current.filter((hotspot) => hotspot.id !== hotspotId)); } + function updateHotspot(id: string, patch: Partial>) { + setHotspots((current) => current.map((h) => (h.id === id ? { ...h, ...patch } : h))); + } + + function toggleHotspotApproval(id: string, approved: boolean) { + updateHotspot(id, { approved }); + } + async function saveHotspots() { if (!product) return; setSavingHotspots(true); @@ -233,8 +377,8 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); - if (conversion) { - await eventsRepo.track(conversion.productId, user.id, 'embed_copied'); + if (product) { + await eventsRepo.track(product.id, user.id, 'embed_copied'); } } @@ -269,6 +413,10 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { async function runAiAction(action: 'analyze' | 'hotspots' | 'description' | 'risk' | 'quality') { if (!product) return; setBusyAction(action); + if (action === 'analyze') { + setDiagnosisLoading(true); + setDiagnosisError(null); + } try { if (action === 'analyze') await apiClient.analyzeProduct(product.id); if (action === 'hotspots') await apiClient.generateHotspots(product.id); @@ -281,9 +429,15 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { setHotspots(refreshed.hotspots); } } catch (actionError) { - setError(actionError instanceof Error ? actionError.message : 'AI action failed'); + const msg = actionError instanceof Error ? actionError.message : 'AI action failed'; + if (action === 'analyze') { + setDiagnosisError(msg); + } else { + setError(msg); + } } finally { setBusyAction(null); + if (action === 'analyze') setDiagnosisLoading(false); } } @@ -310,13 +464,47 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { setMetaForm((current) => ({ ...current, description: saved.description })); } + async function handleApproveProduct(reason?: string) { + if (!product) return; + setApprovingProduct(true); + try { + const saved = await productRepo.save(product.withWorkflowStatus('approved')); + setProduct(saved); + setOverrideOpen(false); + setOverrideReason(''); + if (reason) { + await eventsRepo.track(product.id, user.id, 'product_approved_with_override', { reason }); + } else { + await eventsRepo.track(product.id, user.id, 'product_approved'); + } + } catch (approveError) { + setError(approveError instanceof Error ? approveError.message : 'Failed to approve product'); + } finally { + setApprovingProduct(false); + } + } + + async function handlePublishProduct() { + if (!product) return; + setApprovingProduct(true); + try { + const saved = await productRepo.save(product.withWorkflowStatus('published')); + setProduct(saved); + await eventsRepo.track(product.id, user.id, 'product_published'); + } catch (publishError) { + setError(publishError instanceof Error ? publishError.message : 'Failed to publish product'); + } finally { + setApprovingProduct(false); + } + } + async function handleDownload() { - if (!conversion?.outputAsset) return; + if (!outputAsset) return; setDownloading(true); setDownloadError(null); try { - const filename = conversion.outputAsset.storageKey.split('/').pop() ?? 'model.glb'; - await downloadGlb(conversion.outputAsset.url, filename); + const filename = outputAsset.storageKey.split('/').pop() ?? 'model.glb'; + await downloadGlb(outputAsset.url, filename); } catch (downloadIssue) { setDownloadError(downloadIssue instanceof Error ? downloadIssue.message : 'Download failed'); } finally { @@ -332,7 +520,7 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { ); } - if (!conversion || !product) { + if (!product) { return (
{error ?? t('product.notFound')}
@@ -341,8 +529,45 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { ); } + // WorkflowStatus is the authoritative gate for all publish/export/approval decisions. + // ConversionStatus only controls the 3D pipeline display (pending → processing → done). + const workflowStatus = ProductWorkflowStatus.from(product.workflowStatus); + const importedSourceAssets = toImportedMediaAssets(product.importData?.imageCandidates); + const sourceAssetsForReview = conversion?.sourceAssets?.length ? conversion.sourceAssets : importedSourceAssets; + const outputAsset = conversion?.outputAsset; + const supportsReviewActions = sourceAssetsForReview.length > 0; + const importStatusValues = new Set([ + 'url_submitted', + 'scraping', + 'scrape_failed', + 'extraction_review_needed', + 'autofill_ready', + 'imported_source_images_ready', + 'source_readiness_pending', + ]); + const isImportFlow = product.inputMethod === 'url_import' || !!product.importData; + const isImportReviewState = importStatusValues.has(product.workflowStatus); + + // Phase 4: prefer AI-enriched entries from product analysis; fall back to heuristic derivation. + const sourceImageReadiness = product.aiAnalysis?.sourceImageEntries + ? SourceImageReadiness.fromEntries(product.aiAnalysis.sourceImageEntries) + : sourceAssetsForReview.length > 0 + ? SourceImageReadiness.fromMediaAssets([...sourceAssetsForReview]) + : SourceImageReadiness.fromEntries([]); + const isBlocked = workflowStatus.isBlocked(); // failed_qa — hard block + const isNeedsFix = workflowStatus.value === 'needs_fix'; + const isReadyForReview = workflowStatus.value === 'ready_for_review'; + const isApproved = workflowStatus.value === 'approved'; + const isPublished = workflowStatus.value === 'published'; + const canPublish = workflowStatus.isPublishable(); // approved | published + const canEmbed = workflowStatus.canExport(); + const isAwaitingApproval = conversion?.status.isAwaitingApproval() ?? false; // 3D pipeline state + const isFailed = conversion?.status.value === 'failed' || conversion?.status.value === 'rejected'; // 3D pipeline + const qaScore = conversion?.qualityReport?.score(); + const publicUrl = canPublish ? `${window.location.origin}${product.publicUrl}` : null; + return ( -
+
{error && (
{error}
)} @@ -358,31 +583,86 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { ) : (

{productName}

)} - + + {conversion && }
+ {/* Status banners — seller decision guide driven by workflow status */} + {isBlocked && ( +
+
+
+

Visual QA Failed — Publishing blocked

+

+ AI analysis found critical issues that prevent listing. Readiness score is below 40. Re-upload with better images (at least 3 angles, plain background) to try again. +

+
+
+ )} + {isNeedsFix && ( +
+
!
+
+

Needs Fix — Quality issues detected

+

+ Readiness score is 40–69. Review the AI diagnosis below and fix the flagged issues before approving, or provide an override reason to approve anyway. +

+
+
+ )} + {isReadyForReview && ( +
+
+
+

Ready for Merchant Review

+

AI quality check passed. Review the 3D model and AI diagnosis, then approve to unlock publishing.

+
+
+ )} + {isApproved && ( +
+
+
+

Approved — Ready to publish

+

You have reviewed and approved this product. Click Publish to make it live, or use Embed / Public Page to share.

+
+
+ )} + {isPublished && ( +
+
+
+

Published

+

This product is live. It can be embedded, shared via public page, or exported to Trendyol.

+
+
+ )} +
- {conversion.outputAsset ? ( + {/* E.4 — Load GLB in the viewer when output exists (E.18: even for failed QA) */} + {outputAsset ? ( <> eventsRepo.track(conversion.productId, user.id, 'viewer_loaded').catch(() => null)} - onArOpen={() => eventsRepo.track(conversion.productId, user.id, 'ar_opened').catch(() => null)} + onLoad={() => eventsRepo.track(product.id, user.id, 'viewer_loaded').catch(() => null)} + onArOpen={() => eventsRepo.track(product.id, user.id, 'ar_opened').catch(() => null)} onRotate={() => { const now = Date.now(); if (now - lastRotateEvent.current < 10_000) return; lastRotateEvent.current = now; - eventsRepo.track(conversion.productId, user.id, 'model_rotated').catch(() => null); + eventsRepo.track(product.id, user.id, 'model_rotated').catch(() => null); }} - onSessionEnd={(durationMs) => eventsRepo.track(conversion.productId, user.id, 'session_ended', { duration_ms: durationMs }).catch(() => null)} + onSessionEnd={(durationMs) => eventsRepo.track(product.id, user.id, 'session_ended', { duration_ms: durationMs }).catch(() => null)} onHotspotClick={(hotspotId) => { const hotspot = visibleHotspots.find((item) => item.id === hotspotId); - eventsRepo.track(conversion.productId, user.id, 'hotspot_clicked', { + eventsRepo.track(product.id, user.id, 'hotspot_clicked', { hotspot_id: hotspotId, hotspot_label: hotspot?.label, }).catch(() => null); @@ -404,46 +684,140 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {
)} + ) : conversion && (conversion.status.isProcessing() || conversion.status.isPending()) ? ( + /* E.5 — Loading state while AI is generating the model */ +
+ +

This can take up to 60 seconds

+
) : ( - + /* E.7 — No-model state + E.3 manual fallback explanation */ +
+ + {isFailed ? ( + <> +

3D generation failed

+

+ AI could not generate a 3D model from the provided images. Use the Manual GLB Fallback to upload a model you have prepared — it will still go through merchant review before publishing. +

+ + + ) : isImportFlow ? ( + <> +

No 3D model yet

+

+ URL-imported products can continue through review and quality gates without a GLB. + Add or generate 3D later if you want a public interactive preview. +

+ + ) : ( +

No 3D model yet

+ )} +
)}
- {conversion.outputAsset && ( + {/* Download always available when output exists */} + {outputAsset && ( )} - {conversion.status.isViewable() && conversion.outputAsset && ( - + + {/* Embed — requires approved or published */} + {outputAsset && ( + canEmbed ? ( + + ) : ( + + ) )} - {publicUrl && ( + + {/* Public page link — requires approved or published */} + {publicUrl && canPublish ? ( - Public page ↗ + {t('product.sharePublicPage')} ↗ + ) : !canPublish && outputAsset && ( + )} - {!editMode && conversion.outputAsset && ( + + {/* Hotspot editing — available when output exists */} + {!editMode && outputAsset && ( )} - {conversion.status.isAwaitingApproval() && ( + + {isImportFlow && !outputAsset && supportsReviewActions && ( + + )} + + {/* Product approval gate — driven by workflow status, not conversion status */} + {isReadyForReview && ( + + )} + {isNeedsFix && ( + + )} + {isApproved && ( + + )} + + {/* 3D pipeline approval — keep for awaiting_approval conversion state */} + {isAwaitingApproval && ( <> )} - {conversion.status.isFailed() && ( - + + {/* Fix path for blocked products */} + {(isBlocked || isFailed) && ( + )} - {conversion.status.isViewable() && ( + + {/* Export to Trendyol — requires published */} + {isPublished ? ( + + ) : canPublish ? ( + ) : ( + )} + {downloadError &&

{downloadError}

}
+ {/* Marketplace readiness + export package status */} +
+ {/* QA Score */} + +

{t('product.qaScoreLabel')}

+ {qaScore !== undefined ? ( +
+

= 70 ? 'text-emerald-600' : qaScore >= 40 ? 'text-amber-600' : 'text-red-600'}`}> + {qaScore}/100 +

+
+ ) : ( +

Not scored yet

+ )} + {qaScore !== undefined && ( +
+
= 70 ? 'bg-emerald-500' : qaScore >= 40 ? 'bg-amber-500' : 'bg-red-500'}`} + style={{ width: `${qaScore}%` }} + /> +
+ )} + + + {/* Marketplace readiness */} + +

{t('product.marketplaceReadiness')}

+
+ {canPublish ? ( + + + {t('product.readinessApproved')} + + ) : isBlocked ? ( + + + Visual QA Failed + + ) : isNeedsFix ? ( + + + Needs Fix + + ) : ( + + + {t('product.readinessPending')} + + )} +
+

+ {canPublish ? 'Trendyol · Shopify · Amazon' : 'Listing blocked until approved'} +

+
+ + {/* Export package */} + +

{t('product.exportPackage')}

+
+ {canEmbed ? ( + + + {t('product.exportPackageReady')} + + ) : isBlocked ? ( + + + {t('product.exportPackageBlocked')} + + ) : ( + + + {t('product.exportPackagePending')} + + )} +
+

+ {canEmbed ? 'GLB · preview · catalog metadata' : 'Available after approval'} +

+
+
+
-

Merchant review

-

This is the seller-facing control center for product metadata, AI output, and publish state.

+

Seller review

+

AI quality diagnosis, source images, and product metadata. Approve to unlock publish — or block to request a fix.

{editingMeta ? (
@@ -475,6 +940,209 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { )}
+ {isImportFlow && product.importData && ( +
+
+
+

Imported from URL

+

+ {supportLevelLabel(product.importData.supportLevel)} · {product.importData.domain} · confidence {Math.round(product.importData.overallConfidence * 100)}% +

+

{product.importData.sourceUrl}

+ {product.importData.scrapeTimestamp && ( +

Scraped {new Date(product.importData.scrapeTimestamp).toLocaleString()}

+ )} +
+
+ {product.workflowStatus === 'scrape_failed' && ( + + )} + +
+
+ + {(product.importData.warnings.length > 0 || product.importData.failureReasons.length > 0) && ( +
+ {product.importData.warnings.map((warning) => ( + {warning} + ))} + {product.importData.failureReasons.map((reason) => ( + {reason.replace(/_/g, ' ')} + ))} +
+ )} + + {product.importData.multiProductDetected && product.importData.productClusters && product.importData.productClusters.length > 0 && ( +
+ { + await apiClient.acceptProductCluster(product.id, { clusterId }); + const refreshed = await productRepo.findById(product.id); + if (refreshed) setProduct(refreshed); + }} + /> +
+ )} + + {isImportReviewState && ( +
+
+
+ + setImportForm((current) => ({ ...current, title: event.target.value }))} + className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> +
+
+ + +
+
+
+ +