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): PromiseVisual 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. +
+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. +
+Ready for Merchant Review
+AI quality check passed. Review the 3D model and AI diagnosis, then approve to unlock publishing.
+Approved — Ready to publish
+You have reviewed and approved this product. Click Publish to make it live, or use Embed / Public Page to share.
+Published
+This product is live. It can be embedded, shared via public page, or exported to Trendyol.
+This can take up to 60 seconds
+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
+ )} +{downloadError}
}{t('product.qaScoreLabel')}
+ {qaScore !== undefined ? ( += 70 ? 'text-emerald-600' : qaScore >= 40 ? 'text-amber-600' : 'text-red-600'}`}> + {qaScore}/100 +
+Not scored yet
+ )} + {qaScore !== undefined && ( +{t('product.marketplaceReadiness')}
++ {canPublish ? 'Trendyol · Shopify · Amazon' : 'Listing blocked until approved'} +
+{t('product.exportPackage')}
++ {canEmbed ? 'GLB · preview · catalog metadata' : 'Available after approval'} +
+This is the seller-facing control center for product metadata, AI output, and publish state.
+AI quality diagnosis, source images, and product metadata. Approve to unlock publish — or block to request a fix.
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()}
+ )} +Imported images
+{product.description}
}AI diagnosis failed
+{conversion.errorMessage}
+- Visual QA: {score}/100 —{' '} - {qa.status.replace(/_/g, ' ')} -
-- Category match: {qa.categoryMatch.score}/10 — {qa.categoryMatch.reason} +
+ AI Visual QA Diagnosis +
+ + {score}/100 + ++ {qa.status.replace(/_/g, ' ')} · Category match: {qa.categoryMatch.score}/10 — {qa.categoryMatch.reason}
+ {qa.missingParts.length > 0 && ( -Missing parts:
-Missing from 3D model
+Source image issues:
-Source image issues
+Recommended actions:
-Recommended next actions
+Next action: Re-upload with more image angles
+Use at least 3 photos showing front, back, and side. Avoid busy backgrounds.
+Confidence
-{Math.round(product.aiAnalysis.confidenceScore * 100)}%
-Readiness score
-{product.aiAnalysis.readinessScore ?? '—'}
-Materials
-Missing visuals
-Return-risk factors
-{factor.risk}
-{factor.fix}
-Quality recommendations
-Run product analysis to generate merchant guidance.
- )} -Hotspots ({hotspots.length})
-{t('product.embedModal.description')}
@@ -945,8 +1624,8 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {
{embedType === 'iframe'
- ? buildEmbedSnippet(conversion.outputAsset?.url ?? '', productName, product.id)
- : buildModelViewerSnippet(conversion.outputAsset?.url ?? '')}
+ ? buildEmbedSnippet(outputAsset?.url ?? '', productName, product.id)
+ : buildModelViewerSnippet(outputAsset?.url ?? '')}
Product not found
-This product may have been deleted or the link is incorrect.
- Go to Minimal Block +Product not available
+This product preview is not available. It may not have been published yet.
Product not available
+This product is not yet available for public preview.
+Scan to open on mobile / AR
+Scan to open on any device
{publicUrl}
{product.description}
)} - {product.hotspots.length > 0 && ( + {product.hotspots.filter(hs => hs.position && hs.normal).length > 0 && (Annotations
+Product features
Interact with this product
+Rotate the 3D model above to explore every angle. Tap hotspots to learn about features.
+
- 3D model powered by{' '}
+ Verified 3D product experience powered by{' '}
Minimal Block
diff --git a/apps/web/src/pages/UploadPage.tsx b/apps/web/src/pages/UploadPage.tsx
index 4a60fdc..6355ece 100644
--- a/apps/web/src/pages/UploadPage.tsx
+++ b/apps/web/src/pages/UploadPage.tsx
@@ -23,11 +23,31 @@ function toApiAsset(asset: MediaAsset): ApiMediaAssetInput {
type Mode = '3d' | 'glb';
+const SUPPORTED_IMPORT_DOMAINS = new Set(['amazon.com', 'etsy.com', 'ikea.com', 'trendyol.com', 'minimalblock.demo']);
+
+function parseUrlSupport(value: string): { isValid: boolean; supportLabel?: string } {
+ const trimmed = value.trim();
+ if (!trimmed) return { isValid: false };
+ try {
+ const parsed = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
+ const domain = parsed.hostname.toLowerCase().replace(/^www\./, '');
+ return {
+ isValid: true,
+ supportLabel: SUPPORTED_IMPORT_DOMAINS.has(domain) ? 'Supported domain' : 'Best-effort extraction',
+ };
+ } catch {
+ return { isValid: false };
+ }
+}
+
export function UploadPage({ user }: UploadPageProps) {
const navigate = useNavigate();
const { imageUploader, apiClient } = useApp();
const [mode, setMode] = useState Recommended path
+ Paste a product page URL and Minimal Block will extract product text, import candidate images, autofill missing fields, and route the product into review.
+ {urlError}
+ Mock demo URLs are supported for hackathon reliability. Unknown domains still run in best-effort mode.
+
- {mode === '3d' ? 'Referans görsel ekle ve ürün detaylarını yaz' : 'GLB dosyası ekle ve ürün detaylarını yaz'}
- 3D model oluşturuluyor… Running AI quality analysis… Scoring geometry, visual fidelity, and source readiness Kalite puanı: {score}/100 Kategori: {qa.categoryMatch.score}/10 — {qa.categoryMatch.reason}
+ {isCritical ? 'QA Failed — publish blocked' : isWarning ? 'QA Warning — review required' : 'QA Passed — ready for review'}
+
+ Category match: {qa.categoryMatch.score}/10 — {qa.categoryMatch.reason}
+ Next action: Re-upload with better source images Use 3+ photos: front, back, and side view on a plain background. Şimdi In queue {productDetails}Import from Product URL
+
- {conversion.qualityReport.warnings.map((w) =>
+ {conversion.qualityReport.warnings.map((w) =>
)}
+
+ {isCritical && (
+