diff --git a/apps/api/src/lib/server.ts b/apps/api/src/lib/server.ts index 3b56e25..a953b71 100644 --- a/apps/api/src/lib/server.ts +++ b/apps/api/src/lib/server.ts @@ -172,11 +172,13 @@ async function fetchAssetBase64(asset: MediaAsset): Promise<{ mimeType: string; if (!response.ok) { throw new Error(`Failed to fetch asset: ${response.status}`); } - const arrayBuffer = await response.arrayBuffer(); - return { - mimeType: asset.mimeType, - data: btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))), - }; + const bytes = new Uint8Array(await response.arrayBuffer()); + let binary = ''; + const chunkSize = 8192; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return { mimeType: asset.mimeType, data: btoa(binary) }; } async function uploadGeneratedModel( diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index fef66cb..cd6e000 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -282,4 +282,19 @@ export const en = { cancelBtn: 'Cancel', }, }, + brandPlacement: { + title: 'Brand Placement', + master: 'Enable Brand Placement for this Product', + assetsSection: 'Brand Assets', + description: 'Control which brand elements the AI applies during generation.', + save: 'Save brand settings', + logo: 'Brand Logo', + colors: 'Brand Colors', + text: 'Brand Text', + typography: 'Typography / Fonts', + watermark: 'Watermark', + packaging: 'Packaging Elements', + slogan: 'Brand Slogan', + socialTags: 'Social Media Tags', + }, } as const; diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index dfcfc24..3e84796 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -284,4 +284,19 @@ export const tr = { cancelBtn: 'İptal', }, }, + brandPlacement: { + title: 'Marka Yerleşimi', + master: 'Bu Ürün İçin Marka Yerleşimini Etkinleştir', + assetsSection: 'Marka Varlıkları', + description: 'Yapay zekanın üretim sırasında uygulayacağı marka öğelerini seçin.', + save: 'Marka ayarlarını kaydet', + logo: 'Marka Logosu', + colors: 'Marka Renkleri', + text: 'Marka Metni', + typography: 'Tipografi / Yazı Tipleri', + watermark: 'Filigran', + packaging: 'Ambalaj Öğeleri', + slogan: 'Marka Sloganı', + socialTags: 'Sosyal Medya Etiketleri', + }, } as const; diff --git a/apps/web/src/pages/ProductDetailPage.tsx b/apps/web/src/pages/ProductDetailPage.tsx index 31d6950..3d033b6 100644 --- a/apps/web/src/pages/ProductDetailPage.tsx +++ b/apps/web/src/pages/ProductDetailPage.tsx @@ -13,6 +13,7 @@ import { QualityReport, SourceImageReadiness, generateId, + type BrandPlacementConfig, type ConversionSnapshot, type Hotspot, type Product, @@ -20,7 +21,7 @@ import { type ProductCluster, } from '@minimalblock/core'; 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 { ModelViewer, ModelViewerPlaceholder, ModelInfoCard, StatusBadge, WorkflowStatusBadge, Button, Spinner, Card, Modal, AiDiagnosisPanel, SourceImageReadinessCard, HotspotEditorPanel, BrandPlacementPanel, type ModelViewerHandle } from '@minimalblock/ui'; import { useApp } from '../context/AppContext.js'; import type { SupabaseUser } from '../types.js'; @@ -147,6 +148,8 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { const [approvingProduct, setApprovingProduct] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [diagnosisError, setDiagnosisError] = useState(null); + const [brandPlacement, setBrandPlacement] = useState(null); + const [savingBrandPlacement, setSavingBrandPlacement] = useState(false); const [importForm, setImportForm] = useState({ title: '', description: '', @@ -187,6 +190,7 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { setConversion(foundConversion); setProduct(foundProduct); setHotspots(foundProduct.hotspots); + setBrandPlacement(foundProduct.brandPlacement ?? null); setMetaForm({ name: foundProduct.name, description: foundProduct.description, @@ -245,6 +249,18 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { } } + async function saveBrandPlacement(config: BrandPlacementConfig) { + if (!product) return; + setSavingBrandPlacement(true); + try { + const saved = await productRepo.save(product.withBrandPlacement(config)); + setProduct(saved); + setBrandPlacement(config); + } finally { + setSavingBrandPlacement(false); + } + } + async function saveImportReview() { if (!product) return; setSavingImportReview(true); @@ -1311,6 +1327,30 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) { onContinueAnyway={() => navigate('/upload')} /> + +

Suggested hotspots

diff --git a/libs/core/src/lib/domain/entities/product.entity.ts b/libs/core/src/lib/domain/entities/product.entity.ts index f135be3..1054fa6 100644 --- a/libs/core/src/lib/domain/entities/product.entity.ts +++ b/libs/core/src/lib/domain/entities/product.entity.ts @@ -211,6 +211,54 @@ export interface ProductAiAnalysis { sourceImageEntries?: SourceImageEntry[]; } +export type BrandPlacementKey = 'logo' | 'colors' | 'text' | 'typography' | 'watermark' | 'packaging' | 'slogan' | 'socialTags'; + +export interface BrandPlacementConfig { + enabled: boolean; + logo: boolean; + colors: boolean; + text: boolean; + typography: boolean; + watermark: boolean; + packaging: boolean; + slogan: boolean; + socialTags: boolean; +} + +export const BRAND_PLACEMENT_KEYS: BrandPlacementKey[] = [ + 'logo', 'colors', 'text', 'typography', 'watermark', 'packaging', 'slogan', 'socialTags', +]; + +export const DEFAULT_BRAND_PLACEMENT: BrandPlacementConfig = { + enabled: false, + logo: false, + colors: false, + text: false, + typography: false, + watermark: false, + packaging: false, + slogan: false, + socialTags: false, +}; + +export function buildBrandPlacementPrompt(config: BrandPlacementConfig): string { + if (!config.enabled) return ''; + const enabled: string[] = []; + const disabled: string[] = []; + if (config.logo) enabled.push('brand logo'); else disabled.push('brand logo'); + if (config.colors) enabled.push('brand colors'); else disabled.push('brand colors'); + if (config.text) enabled.push('brand text'); else disabled.push('brand text'); + if (config.typography) enabled.push('typography/fonts'); else disabled.push('typography/fonts'); + if (config.watermark) enabled.push('subtle watermark styling'); else disabled.push('watermark'); + if (config.packaging) enabled.push('packaging elements'); else disabled.push('packaging elements'); + if (config.slogan) enabled.push('brand slogan'); else disabled.push('brand slogan'); + if (config.socialTags) enabled.push('social media tags'); else disabled.push('social media tags'); + const parts: string[] = []; + if (enabled.length > 0) parts.push(`Use: ${enabled.join(', ')}.`); + if (disabled.length > 0) parts.push(`Do not include: ${disabled.join(', ')}.`); + return parts.join(' '); +} + export interface ProductProps { id: string; name: string; @@ -224,6 +272,7 @@ export interface ProductProps { workflowStatus?: ProductWorkflowStatusValue; inputMethod?: ProductInputMethod; importData?: ProductImportData | null; + brandPlacement?: BrandPlacementConfig | null; createdAt: Date; updatedAt: Date; } @@ -241,6 +290,7 @@ export class Product { readonly workflowStatus: ProductWorkflowStatusValue; readonly inputMethod: ProductInputMethod; readonly importData: ProductImportData | null; + readonly brandPlacement: BrandPlacementConfig | null; readonly createdAt: Date; readonly updatedAt: Date; @@ -257,6 +307,7 @@ export class Product { this.workflowStatus = props.workflowStatus ?? 'draft'; this.inputMethod = props.inputMethod ?? 'manual_upload'; this.importData = props.importData ?? null; + this.brandPlacement = props.brandPlacement ?? null; this.createdAt = props.createdAt; this.updatedAt = props.updatedAt; } @@ -414,6 +465,10 @@ export class Product { return new Product({ ...this, aiAnalysis, updatedAt: new Date() }); } + withBrandPlacement(brandPlacement: BrandPlacementConfig | null): Product { + return new Product({ ...this, brandPlacement, updatedAt: new Date() }); + } + withSuggestedHotspots(hotspotsSuggested: SuggestedHotspot[]): Product { return new Product({ ...this, hotspotsSuggested, updatedAt: new Date() }); } diff --git a/libs/data/src/lib/repositories/product.repository.ts b/libs/data/src/lib/repositories/product.repository.ts index 1f5d8bf..58bfe30 100644 --- a/libs/data/src/lib/repositories/product.repository.ts +++ b/libs/data/src/lib/repositories/product.repository.ts @@ -5,6 +5,7 @@ import { ProductAiAnalysis, ProductImportData, SuggestedHotspot, + BrandPlacementConfig, migrateLegacyProductCategory, } from '@minimalblock/core'; import type { SupabaseClient } from '@supabase/supabase-js'; @@ -32,6 +33,9 @@ function rowToProduct(row: ProductRow): Product { importData: row.import_data && !Array.isArray(row.import_data) ? (row.import_data as unknown as ProductImportData) : null, + brandPlacement: row.brand_placement && !Array.isArray(row.brand_placement) + ? (row.brand_placement as unknown as BrandPlacementConfig) + : null, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), }); @@ -82,6 +86,9 @@ export class SupabaseProductRepository implements IProductRepository { import_data: product.importData ? product.importData as unknown as import('../supabase/database.types.js').Json : null, + brand_placement: product.brandPlacement + ? product.brandPlacement as unknown as import('../supabase/database.types.js').Json + : null, }) .select() .single(); diff --git a/libs/data/src/lib/supabase/database.types.ts b/libs/data/src/lib/supabase/database.types.ts index c23fd22..fb552ba 100644 --- a/libs/data/src/lib/supabase/database.types.ts +++ b/libs/data/src/lib/supabase/database.types.ts @@ -31,6 +31,7 @@ export type Database = { workflow_status: string; input_method: string; import_data: Json | null; + brand_placement: Json | null; created_at: string; updated_at: string; }; @@ -48,6 +49,7 @@ export type Database = { workflow_status?: string; input_method?: string; import_data?: Json | null; + brand_placement?: Json | null; created_at?: string; updated_at?: string; }; @@ -65,6 +67,7 @@ export type Database = { workflow_status?: string; input_method?: string; import_data?: Json | null; + brand_placement?: Json | null; created_at?: string; updated_at?: string; }; diff --git a/libs/features/src/lib/gallery/hooks/use-gallery.ts b/libs/features/src/lib/gallery/hooks/use-gallery.ts index ac89146..b091cdf 100644 --- a/libs/features/src/lib/gallery/hooks/use-gallery.ts +++ b/libs/features/src/lib/gallery/hooks/use-gallery.ts @@ -2,8 +2,6 @@ import { useState, useEffect, useRef } from 'react'; import { Conversion } from '@minimalblock/core'; import type { IConversionRepository, IProductRepository } from '@minimalblock/core'; -const POLL_INTERVAL_MS = 3000; - export interface UseGalleryState { conversions: Conversion[]; loading: boolean; @@ -16,27 +14,8 @@ export function useGallery( ownerId: string, ) { const [state, setState] = useState({ conversions: [], loading: true, error: null }); - const pollTimerRef = useRef | null>(null); const conversionsRef = useRef([]); - function hasNonTerminal(list: Conversion[]) { - return list.some(c => !c.status.isTerminal()); - } - - function scheduleRefresh(list: Conversion[]) { - if (!hasNonTerminal(list)) return; - pollTimerRef.current = setTimeout(async () => { - try { - const fresh = await conversionRepo.findByOwnerId(ownerId); - conversionsRef.current = fresh; - setState(s => ({ ...s, conversions: fresh })); - scheduleRefresh(fresh); - } catch { - // silently ignore poll errors - } - }, POLL_INTERVAL_MS); - } - useEffect(() => { let cancelled = false; @@ -44,25 +23,18 @@ export function useGallery( if (cancelled) return; conversionsRef.current = conversions; setState({ conversions, loading: false, error: null }); - scheduleRefresh(conversions); }).catch(err => { if (!cancelled) setState({ conversions: [], loading: false, error: err instanceof Error ? err.message : 'Failed to load' }); }); - return () => { - cancelled = true; - if (pollTimerRef.current) clearTimeout(pollTimerRef.current); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps + return () => { cancelled = true; }; }, [conversionRepo, ownerId]); const removeProduct = async (productId: string) => { - if (pollTimerRef.current) clearTimeout(pollTimerRef.current); await productRepo.delete(productId); const updated = conversionsRef.current.filter(c => c.productId !== productId); conversionsRef.current = updated; setState(s => ({ ...s, conversions: updated })); - scheduleRefresh(updated); }; return { ...state, removeProduct }; diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 87ffcee..5001ea3 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -23,6 +23,9 @@ export type { WorkflowStatusBadgeProps } from './lib/components/WorkflowStatusBa export { AiDiagnosisPanel } from './lib/components/AiDiagnosisPanel.js'; export type { AiDiagnosisPanelProps } from './lib/components/AiDiagnosisPanel.js'; +export { BrandPlacementPanel } from './lib/components/BrandPlacementPanel.js'; +export type { BrandPlacementPanelProps } from './lib/components/BrandPlacementPanel.js'; + export { SourceImageReadinessCard } from './lib/components/SourceImageReadinessCard.js'; export type { SourceImageReadinessCardProps } from './lib/components/SourceImageReadinessCard.js'; diff --git a/libs/ui/src/lib/components/BrandPlacementPanel.tsx b/libs/ui/src/lib/components/BrandPlacementPanel.tsx new file mode 100644 index 0000000..94fabee --- /dev/null +++ b/libs/ui/src/lib/components/BrandPlacementPanel.tsx @@ -0,0 +1,111 @@ +import type { BrandPlacementConfig, BrandPlacementKey } from '@minimalblock/core'; +import { DEFAULT_BRAND_PLACEMENT, BRAND_PLACEMENT_KEYS } from '@minimalblock/core'; +import { Spinner } from './Spinner.js'; + +export interface BrandPlacementPanelProps { + value: BrandPlacementConfig | null; + onChange: (config: BrandPlacementConfig) => void; + onSave: (config: BrandPlacementConfig) => Promise | void; + isSaving: boolean; + labels: { + title: string; + master: string; + assetsSection: string; + save: string; + description: string; + options: Record; + }; +} + +function resolvedConfig(value: BrandPlacementConfig | null): BrandPlacementConfig { + return value ?? DEFAULT_BRAND_PLACEMENT; +} + +export function BrandPlacementPanel({ value, onChange, onSave, isSaving, labels }: BrandPlacementPanelProps) { + const config = resolvedConfig(value); + + function toggleMaster() { + onChange({ ...config, enabled: !config.enabled }); + } + + function toggleOption(key: BrandPlacementKey) { + onChange({ ...config, [key]: !config[key] }); + } + + return ( +
+
+

{labels.title}

+ {isSaving && } +
+

{labels.description}

+ + {/* Master toggle */} + + + {/* Brand Assets section — shown only when enabled */} + {config.enabled && ( +
+

+ {labels.assetsSection} +

+
    + {BRAND_PLACEMENT_KEYS.map((key) => ( +
  • + +
  • + ))} +
+ +
+ +
+
+ )} +
+ ); +} diff --git a/supabase/migrations/015_brand_placement.sql b/supabase/migrations/015_brand_placement.sql new file mode 100644 index 0000000..e0a5c50 --- /dev/null +++ b/supabase/migrations/015_brand_placement.sql @@ -0,0 +1 @@ +ALTER TABLE products ADD COLUMN IF NOT EXISTS brand_placement jsonb DEFAULT NULL;