// ============================================================================ // StyleKro — SHOPWISE module (split from app.jsx on 2026-06-26). // Shop browse/detail/settings + seller manage + buyer/seller garment detail and // the shared category form-field components (TaxonomySelect, CategoryExtraFields, // ...). Reuses shopcat/* registries. Depends on common.jsx (loaded first). // ============================================================================ // ===== Shops browse page ===== function ShopsPage() { const { apiCall, t } = useAuth(); const [shops, setShops] = useState([]); const [q, setQ] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [filterCat, setFilterCat] = useState(''); // Fashion Category key const [filterLoc, setFilterLoc] = useState(''); // city/location filter const [minPieces, setMinPieces] = useState(0); // 0 = any const [filtersOpen, setFiltersOpen] = useState(false); const [locating, setLocating] = useState(false); const [nearMeNote, setNearMeNote] = useState(''); useEffect(() => { document.body.classList.toggle('filters-open', filtersOpen); return () => document.body.classList.remove('filters-open'); }, [filtersOpen]); const load = useCallback(async (query) => { setLoading(true); setError(''); try { const path = query ? `/shops?q=${encodeURIComponent(query)}` : '/shops'; const list = await apiCall(path); setShops(list || []); } catch (e) { setError(e.message); } finally { setLoading(false); } }, [apiCall]); useEffect(() => { load(''); }, [load]); // Detect the user's city via geolocation + Google reverse-geocode and // auto-pick the matching LOCATION filter chip. Falls back to a hint if no // shop matches the detected area. async function findNearMe() { setNearMeNote(''); if (!navigator.geolocation) { setNearMeNote('Geolocation is not supported by this browser.'); return; } setLocating(true); try { const pos = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 12000, maximumAge: 60000, }); }); const { latitude: lat, longitude: lng } = pos.coords; const google = await loadGoogleMaps(); const geocoder = new google.maps.Geocoder(); const results = await new Promise((resolve, reject) => { geocoder.geocode({ location: { lat, lng } }, (r, status) => { if (status === 'OK' && r && r[0]) resolve(r); else reject(new Error(status)); }); }); // Pull candidate place names: locality (city), sublocality, area. const tokens = new Set(); for (const res of results) { for (const c of res.address_components || []) { if ((c.types || []).some((t) => ['locality', 'sublocality', 'administrative_area_level_2', 'administrative_area_level_1'].includes(t))) { if (c.long_name) tokens.add(c.long_name.toLowerCase()); if (c.short_name) tokens.add(c.short_name.toLowerCase()); } } } // Find the best matching shop location bucket. const locKeys = Object.keys(locCounts); let match = ''; for (const k of locKeys) { const lk = k.toLowerCase(); for (const tok of tokens) { if (lk.includes(tok) || tok.includes(lk)) { match = k; break; } } if (match) break; } if (match) { setFilterLoc(match); setNearMeNote(`Showing shops in ${match}.`); } else { const detected = Array.from(tokens)[0] || `${lat.toFixed(3)}, ${lng.toFixed(3)}`; setNearMeNote(`No shops match your area (${detected}).`); } } catch (err) { const msg = err && err.code === 1 ? 'Permission denied. Allow location access to use "Near me".' : (err && err.message) || 'Could not get your location.'; setNearMeNote(msg); } finally { setLocating(false); } } function onSubmit(e) { e.preventDefault(); load(q.trim()); } // Detect a category from the shop name + description so we can show a badge. // The shop owner's explicit ``shop.category`` takes priority; the keyword // matcher is only a fallback for legacy shops that haven't set one. function categoryFor(s) { const cats = [ { k: 'garment', label: 'Fashion', match: /garment|cloth|dress|shirt|tshirt|jean|trouser|kurta|saree|lehenga|jacket|coat|sweater|hoodie|blazer|skirt|outfit|apparel/ }, { k: 'tailor', label: 'Tailor', match: /tailor|tailoring|bespoke|custom stitch|fabric|textile|design/ }, { k: 'salon', label: 'Salon', match: /hair|salon|saloon|barber|haircut|wig|extensions|makeup|cosmetic|lipstick|beauty|mua|nail art|nailart|manicure|pedicure/ }, { k: 'tile', label: 'Tile & Paint', match: /tile|paint|emulsion|distemper|flooring|granite|ceramic|porcelain|vitrified|stone/ }, { k: 'tattoo', label: 'Tattoo', match: /tattoo|ink|body art/ }, { k: 'jewelry', label: 'Jewelry', match: /jewel|necklace|earring|bracelet|ring|pendant/ }, { k: 'eyewear', label: 'Eyewear', match: /glasses|sunglass|spectacle|optical|frames|eyewear/ }, { k: 'watch', label: 'Watches', match: /watch|timepiece|wristwatch/ }, ]; const labelMap = Object.fromEntries(cats.map((c) => [c.k, c.label])); if (s.category) { const k = String(s.category).toLowerCase(); return { k, label: labelMap[k] || (k.charAt(0).toUpperCase() + k.slice(1)) }; } const t = `${s.name || ''} ${s.description || ''}`.toLowerCase(); for (const c of cats) if (c.match.test(t)) return { k: c.k, label: c.label }; return { k: 'fashion', label: 'Fashion' }; } function avatarFor(s) { return (s.name || '?').trim().charAt(0).toUpperCase(); } const totalPieces = shops.reduce((n, s) => n + (s.garment_count || 0), 0); // Build the Fashion Category filter chip list from the loaded shops. const catCounts = shops.reduce((m, s) => { const c = categoryFor(s); if (!m.has(c.k)) m.set(c.k, { label: c.label, count: 0 }); m.get(c.k).count += 1; return m; }, new Map()); const catChips = Array.from(catCounts.entries()) .map(([k, v]) => ({ k, label: v.label, count: v.count })) .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); let displayed = shops.filter((s) => { if (filterCat && categoryFor(s).k !== filterCat) return false; if (filterLoc && (s.location || '').trim() !== filterLoc) return false; if (minPieces > 0 && (s.garment_count || 0) < minPieces) return false; return true; }); // Location counts (for the rail) const locCounts = makeCountMap(shops, (s) => (s.location || '').trim(), { skipEmpty: true }); const anyShopFilter = !!filterCat || !!filterLoc || minPieces > 0 || !!q; return (
{error &&

{error}

} {loading ? (
{[0, 1, 2, 3, 4, 5].map((i) => (
))}
) : shops.length === 0 ? (
🛍

{t('shops.noShopsFound')}

{q ? t('shops.tryDifferent') : t('shops.beFirst')}

{q ? ( ) : ( {t('shop.open')} → )}
) : (
{filtersOpen &&
setFiltersOpen(false)} />}
setQ(e.target.value)} />
{displayed.length === 0 ? (
🛍

{t('shops.noMatch')}

{t('shops.tryDifferentFilter')}

) : displayed.map((s) => { const cat = categoryFor(s); const locShort = s.location ? (() => { const words = s.location.trim().split(/\s+/); return words.length > 3 ? words.slice(0, 3).join(' ') + '…' : s.location; })() : ''; return (
{avatarFor(s)}

{s.name}

{cat.label} {s.location && (
📍 {locShort}
)} {s.description &&

{s.description}

}
{s.garment_count} piece{s.garment_count === 1 ? '' : 's'} {t('shops.explore')}
); })}
)}
); } // ===== Shop detail page ===== // Buyer-facing tattoo placement chips for the inline studio. Selecting a limb // part + the "wrap" toggle produces an armband-style instruction; otherwise a // flat placement. The chosen phrase is sent as the try-on `note` (which the // backend treats as a high-priority user instruction, overriding the seller's // per-design default). `auto` sends no note so the seller default / base // prompt decide. // Instruction sent with the composited tattoo image: the design has already // been positioned/scaled/rotated onto the photo client-side, so the AI only // needs to render it as believable ink at that exact spot. const TATTOO_COMPOSITE_NOTE = 'A tattoo design has been digitally overlaid onto this photo at the exact position, size and rotation chosen by the user. Render it as real ink on the skin at that same location and scale: follow the body\'s contours and perspective, match the skin\'s lighting and shading, remove any white or transparent background from the design, and blend the edges naturally so it looks like a real tattoo. Do not move, resize, rotate, or duplicate it, and do not change the person otherwise.'; function ShopDetailPage() { const { id } = useParams(); const { apiCall, token, profile, ready: authReady, t, photoVersion } = useAuth(); const ensureProfilePhoto = useRequireProfilePhoto(); const navigate = useNavigate(); const [shop, setShop] = useState(null); const [garments, setGarments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [selected, setSelected] = useState(() => new Set()); const [generating, setGenerating] = useState(false); const [search, setSearch] = useState(''); const [tailorDesignSearch, setTailorDesignSearch] = useState(''); const [tailorFabricSearch, setTailorFabricSearch] = useState(''); const [filterGroup, setFilterGroup] = useState(''); const [filterSub, setFilterSub] = useState(''); // results: map of garment_id -> { status: 'pending'|'done'|'error', image_url?, error? } const [results, setResults] = useState({}); // garment_ids currently flipped to the "Original" face. Click toggles. const [flipped, setFlipped] = useState(() => new Set()); const [linkCopied, setLinkCopied] = useState(false); // Combined-look result (when user selects multiple items and tries them together) const [combinedResult, setCombinedResult] = useState(null); // { status, image_url?, error?, titles } const [shopExpanded, setShopExpanded] = useState(false); // Filter sheet (replaces the inline category rows) const [filterOpen, setFilterOpen] = useState(false); // Tailor: individual garment pieces the buyer wants stitched. Empty means // stitch the selected design as shown. const [tailorPieceKeys, setTailorPieceKeys] = useState([]); const [tailorFabricAssignments, setTailorFabricAssignments] = useState({}); const [activeTailorFabricSlot, setActiveTailorFabricSlot] = useState(''); const [tailorPiecesExpanded, setTailorPiecesExpanded] = useState(false); const [tailorAnimatingId, setTailorAnimatingId] = useState(null); const [tailorSetupPulse, setTailorSetupPulse] = useState(''); const [tailorSetupFlipped, setTailorSetupFlipped] = useState(false); const tailorSetupRef = useRef(null); const tailorDesignsRef = useRef(null); const tailorFabricsRef = useRef(null); // Lightbox URL for the fullscreen zoom view. const [lightbox, setLightbox] = useState(null); const [sheet360MsgId, setSheet360MsgId] = useState(null); // When true, the 360° spin renders INLINE inside the tile/tattoo studio // canvas (instead of the popup modal), mirroring the buyer-item hero 360. const [studio360, setStudio360] = useState(false); const fetchCombinedSheet360 = useCallback( () => fetchSheet360( `/chat/messages/${sheet360MsgId}/sheet360` + (shop?.category ? `?category=${encodeURIComponent(shop.category)}` : ''), ), [sheet360MsgId, shop], ); function openStudio360(messageId) { if (!messageId) return; setSheet360MsgId(messageId); setStudio360(true); } function closeStudio360() { setStudio360(false); setSheet360MsgId(null); } const isTailorShop = (shop?.category || '').toLowerCase() === 'tailor'; const isTileShop = (shop?.category || '').toLowerCase() === 'tile'; const isTattooShop = (shop?.category || '').toLowerCase() === 'tattoo'; // Garment-piece options this tailor shop offers. If the seller has not // configured any, fall back to the full master list (keeps old shops working). const tailorPieceChoices = (() => { const gender = (shop?.shop_gender || '').toLowerCase(); let base = TAILOR_PIECE_OPTIONS; if (gender === 'men' || gender === 'women') { const g = base.filter((o) => (o.group || 'men') === gender); if (g.length) base = g; } const cfg = Array.isArray(shop?.tailor_pieces) ? shop.tailor_pieces : []; if (!cfg.length) return base; const filtered = base.filter((o) => cfg.includes(o.value)); return filtered.length ? filtered : base; })(); const tailorSlotChoices = (() => { const seen = new Set(); const out = []; for (const opt of tailorPieceChoices) { for (const slot of tailorSlotsForOption(opt)) { if (!seen.has(slot.key)) { seen.add(slot.key); out.push(slot); } } } return out; })(); const selectedTailorSlots = tailorSlotChoices.filter((slot) => tailorPieceKeys.includes(slot.key)); const selectedTailorPreset = tailorPresetForSlots(tailorPieceKeys); const selectedTailorLabel = selectedTailorSlots.length ? selectedTailorSlots.map((s) => s.label).join(' + ') : ''; const tailorPrimarySlots = tailorSlotChoices.filter((slot) => TAILOR_PRIMARY_SLOT_KEYS.includes(slot.key)); const tailorMoreSlots = tailorSlotChoices.filter((slot) => !TAILOR_PRIMARY_SLOT_KEYS.includes(slot.key)); const visibleTailorSlots = tailorPiecesExpanded ? [...tailorPrimarySlots, ...tailorMoreSlots] : tailorPrimarySlots; const tailorMoreSelected = tailorMoreSlots.some((slot) => tailorPieceKeys.includes(slot.key)); useEffect(() => { if (!isTailorShop) return; const allowed = new Set(tailorSlotChoices.map((slot) => slot.key)); setTailorPieceKeys((cur) => cur.filter((key) => allowed.has(key))); setTailorFabricAssignments((cur) => Object.fromEntries(Object.entries(cur).filter(([key]) => allowed.has(key)))); setActiveTailorFabricSlot((cur) => (allowed.has(cur) ? cur : '')); }, [isTailorShop, shop?.id, Array.isArray(shop?.tailor_pieces) ? shop.tailor_pieces.join('|') : '']); const garmentKind = (g) => g?.asset_kind || shopMeta(shop).defaultAssetKind; const [tileRoomPrompt, setTileRoomPrompt] = useState(null); // Defined in web/shops/tile.js; keep the same name for local readability. const TILE_ROOM_SURFACES = (_ShopCategories?.byKey?.tile?.ROOM_SURFACES) || []; // Inline StyleKro Room studio (tile/paint shops): assign a material to each // room surface, then generate one styled-room image — no popup. const [tileAssignments, setTileAssignments] = useState({}); const [activeTileSurface, setActiveTileSurface] = useState('floor'); const [tileRoomSearch, setTileRoomSearch] = useState(''); const [tileCat, setTileCat] = useState('All'); const [tileFavs, setTileFavs] = useState(() => new Set()); // Track WHICH room-photo src has finished loading rather than a boolean that // a passive effect resets to false: a freshly captured camera photo becomes a // data: URL that can fire onLoad before a passive reset effect flushes, // which on phones left the "Loading room photo..." pill stuck forever. Deriving // the loading state from the loaded src removes that ordering race entirely. const [loadedRoomSrc, setLoadedRoomSrc] = useState(null); // One-shot fresh room snapshot captured from the device camera. When set, // it overrides the user's stored profile photo for the tile-room preview. const [cameraRoomPhoto, setCameraRoomPhoto] = useState(null); const roomCameraRef = useRef(null); const roomCanvasRef = useRef(null); // Scroll the room canvas into view whenever a new tile-room styling starts // so the user sees the generated image without manual scrolling. useEffect(() => { if ((combinedResult?.kind === 'tile-room' || combinedResult?.kind === 'tattoo') && combinedResult?.status === 'pending') { roomCanvasRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }, [combinedResult?.kind, combinedResult?.status]); function selectTileSurfaceAndScroll(surface) { setActiveTileSurface(surface); window.setTimeout(() => { const picker = document.querySelector('.shop-detail-v2 .trs-picker-v2'); if (picker) picker.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 0); } function assignTileToSurface(gid) { setTileAssignments((cur) => { if (cur[activeTileSurface] === gid) { const next = { ...cur }; delete next[activeTileSurface]; return next; } return { ...cur, [activeTileSurface]: gid }; }); } // Tailor multi-piece outfit: assign one of the selected fabrics to each // garment piece (coat/pant/shirt…) before generating ONE combined outfit. // { design, slots: [{key,label}], fabrics: Garment[], assignments: {slotKey: fabricId}, error } const [pieceFabricPrompt, setPieceFabricPrompt] = useState(null); // Tattoo studio — inline canvas (no popup). Uses the buyer's profile photo by // default; an uploaded camera/gallery photo overrides it for this try-on. // The photo is sent as a multipart file (never base64-in-JSON). const [tattooDesignId, setTattooDesignId] = useState(null); // Optional uploaded photo: upload-ready Blob in a ref + an object URL for the // on-screen preview. Clearing it falls back to the profile photo. const [tattooPhotoUrl, setTattooPhotoUrl] = useState(null); const tattooPhotoFileRef = useRef(null); const tattooFileInputRef = useRef(null); // Interactive overlay editor: the selected tattoo is shown on the photo and // the buyer can drag (move), handle-resize and rotate it. The chosen // transform is baked into the image sent to the AI on generate. // xPct/yPct = centre position as % of the 4:5 canvas box // wPct = tattoo width as % of the canvas width // rot = rotation in degrees const [tatXf, setTatXf] = useState({ xPct: 50, yPct: 42, wPct: 34, rot: 0 }); const tatXfRef = useRef(tatXf); const setTat = (next) => setTatXf((p) => { const n = typeof next === 'function' ? next(p) : next; tatXfRef.current = n; return n; }); const tatCanvasRef = useRef(null); const tatGesture = useRef(null); const tatNatRef = useRef({ w: 1, h: 1 }); async function promptTileRoom(picks) { if (!picks || picks.length === 0) return; if (!(await ensureProfilePhoto())) return; const assignments = {}; TILE_ROOM_SURFACES.forEach((surface, idx) => { assignments[surface.value] = picks[idx]?.id || (idx === 0 ? picks[0]?.id || '' : ''); }); setTileRoomPrompt({ picks, assignments, error: '' }); } function tileRoomTitle(picks, assignments) { const byId = Object.fromEntries(picks.map((g) => [g.id, g])); return TILE_ROOM_SURFACES.map((surface) => { const selectedId = assignments[surface.value]; const item = selectedId ? byId[selectedId] : null; return item ? `${surface.label}: ${item.title || 'Item'}` : null; }).filter(Boolean).join(' · '); } const shareLink = shop ? `${window.location.protocol}//${window.location.host}${window.location.pathname}#/shops/${shop.id}` : ''; async function copyShareLink() { if (!shareLink) return; try { await navigator.clipboard.writeText(shareLink); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 1800); } catch { try { const ta = document.createElement('textarea'); ta.value = shareLink; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 1800); } catch {} } } async function shareShop() { if (!shareLink) return; const data = { title: shop?.name ? `${shop.name} · StyleKro` : 'StyleKro Shop', text: shop?.name ? `Explore ${shop.name} on StyleKro and try on any piece with AI before you buy.` : 'Explore this shop on StyleKro and try on any piece with AI before you buy.', url: shareLink, }; if (navigator.share) { try { await navigator.share(data); return; } catch {} } copyShareLink(); } useEffect(() => { let cancelled = false; (async () => { setLoading(true); try { const [s, g] = await Promise.all([ apiCall(`/shops/${id}`), apiCall(`/shops/${id}/garments`), ]); if (cancelled) return; setShop(s); setGarments(sortByCreatedDesc(g || [])); } catch (e) { if (!cancelled) setError(e.message); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [id, apiCall]); // Restore previously-saved try-ons so the user can come back and see // any looks they generated in earlier sessions. Also restores the // spinner for any tryon still in flight (e.g. user refreshed mid-generation). useEffect(() => { if (!token) return; let cancelled = false; const fetchPrev = async () => { try { const prev = await apiCall(`/shops/${id}/my-tryons`); if (cancelled || !prev) return null; setResults((cur) => { const next = { ...cur }; for (const [gid, info] of Object.entries(prev)) { const existing = next[gid]; const hasImage = info && info.image_url; const isPending = info && info.pending; // Don't overwrite an in-flight local stream that's already further along. if (existing && existing.status === 'done') continue; if (existing && existing.status === 'fast' && !hasImage) continue; if (isPending && hasImage) { // FAST image is ready but SLOW is still cooking — show partial + refining hint. next[gid] = { status: 'fast', image_url: imgUrl(info.image_url), saved: true, merge_id: info.merge_id || null, message_id: info.message_id || (existing && existing.message_id) || null, }; } else if (isPending) { next[gid] = { status: 'pending', message_id: info.message_id }; } else if (hasImage) { next[gid] = { status: 'done', image_url: imgUrl(info.image_url), saved: true, merge_id: info.merge_id || null }; } } // If a card was marked pending locally but the server no longer // reports it (e.g. job aged out or was abandoned), drop the spinner. for (const [gid, existing] of Object.entries(next)) { if (existing && existing.status === 'pending' && !prev[gid]) { delete next[gid]; } } return next; }); return prev; } catch (_e) { return null; } }; let pollId = null; const startPolling = () => { if (pollId) return; pollId = setInterval(async () => { const prev = await fetchPrev(); if (cancelled) return; const stillPending = prev && Object.values(prev).some((info) => info && info.pending); if (!stillPending && pollId) { clearInterval(pollId); pollId = null; } }, 4000); }; (async () => { const prev = await fetchPrev(); if (cancelled || !prev) return; const hasPending = Object.values(prev).some((info) => info && info.pending); if (hasPending) startPolling(); })(); return () => { cancelled = true; if (pollId) clearInterval(pollId); }; }, [id, token, apiCall]); function scrollToTailorRef(ref) { ref?.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function pulseTailorSetup(kind) { setTailorSetupPulse(kind); window.setTimeout(() => setTailorSetupPulse(''), 900); } function toggle(gid) { const garment = garments.find((g) => g.id === gid); if (!garment) return; const kind = garmentKind(garment); const selecting = !selected.has(gid); if (isTailorShop && selecting && (kind === 'design' || kind === 'fabric')) { setTailorAnimatingId(gid); window.setTimeout(() => setTailorAnimatingId(null), 650); pulseTailorSetup(kind); window.setTimeout(() => scrollToTailorRef(tailorSetupRef), 220); } setSelected((prev) => { const next = new Set(prev); if (!isTailorShop) { if (next.has(gid)) next.delete(gid); else next.add(gid); return next; } if (kind === 'design') { const wasSelected = next.has(gid); for (const id of Array.from(next)) { const picked = garments.find((item) => item.id === id); if (garmentKind(picked) === 'design') next.delete(id); } if (!wasSelected) next.add(gid); return next; } if (kind === 'fabric' && selectedTailorSlots.length > 0) { const slotKey = activeTailorFabricSlot || selectedTailorSlots.find((slot) => !tailorFabricAssignments[slot.key])?.key || selectedTailorSlots[0].key; const prevFabricId = tailorFabricAssignments[slotKey]; const nextAssignments = { ...tailorFabricAssignments, [slotKey]: gid }; setTailorFabricAssignments(nextAssignments); if (prevFabricId && prevFabricId !== gid && !Object.entries(nextAssignments).some(([key, value]) => key !== slotKey && value === prevFabricId)) { next.delete(prevFabricId); } next.add(gid); setActiveTailorFabricSlot(''); return next; } if (next.has(gid)) next.delete(gid); else next.add(gid); return next; }); } function clearSelection() { setSelected(new Set()); } async function runTryon(picks, opts = {}) { if (!picks || picks.length === 0) return; const { note } = opts; // TEMP DIAGNOSTIC: tag which handler fired so the backend log can name it. const bodyJson = JSON.stringify(note ? { note, _diag: { _src: 'generic' } } : { _diag: { _src: 'generic' } }); const extraHeaders = { 'Content-Type': 'application/json' }; setError(''); setResults((cur) => { const next = { ...cur }; for (const g of picks) next[g.id] = { status: 'pending' }; return next; }); await Promise.all(picks.map((g) => _StyleKroGen.runTryon({ streamSSE, url: `/garments/${g.id}/tryon/stream`, token, body: bodyJson, headers: extraHeaders, onUpdate: ({ status, image_url, pending_text, message_id, error }) => { setResults((cur) => { if (status === 'error') return { ...cur, [g.id]: { status: 'error', error } }; const prev = cur[g.id] || {}; const next = { ...prev, status }; if (pending_text !== undefined) next.pending_text = pending_text; next.message_id = message_id || prev.message_id; if (image_url !== undefined) next.image_url = imgUrl(image_url); return { ...cur, [g.id]: next }; }); }, }))); } async function generateCombined() { if (selected.size === 0 || generating) return; const picks = garments.filter((g) => selected.has(g.id)); setGenerating(true); const titles = picks.map((g) => g.title).join(', '); setCombinedResult({ status: 'pending', titles, kind: 'combine' }); await _StyleKroGen.runTryon({ streamSSE, url: '/garments/tryon-multi/stream', token, body: JSON.stringify({ garment_ids: picks.map((g) => g.id) }), headers: { 'Content-Type': 'application/json' }, onUpdate: ({ status, image_url, pending_text, message_id, error }) => { setCombinedResult((cur) => { if (status === 'error') return { status: 'error', titles, kind: 'combine', error }; const next = { status, titles, kind: 'combine' }; if (pending_text !== undefined) next.pending_text = pending_text; if (image_url !== undefined) next.image_url = imgUrl(image_url); next.message_id = message_id || cur?.message_id; return next; }); }, }); setGenerating(false); clearSelection(); } async function confirmTileRoomTryon(autoAssign = false, picksOverride = null, assignmentsOverride = null) { const inline = !!picksOverride; const picks = picksOverride || tileRoomPrompt?.picks; const assignments = assignmentsOverride || (!inline && tileRoomPrompt?.assignments) || {}; if (!picks || picks.length === 0) return; if (inline && !(await ensureProfilePhoto())) return; const placements = TILE_ROOM_SURFACES.map((surface) => ({ surface: surface.value, garment_id: assignments[surface.value], })).filter((placement) => placement.garment_id); if (!autoAssign && placements.length === 0) { if (inline) setError('Add a tile to at least one surface to style the room.'); else setTileRoomPrompt((cur) => cur ? { ...cur, error: 'Choose at least one room surface to style.' } : cur); return; } if (autoAssign && picks.length > 6) { if (inline) setError('Select up to 6 materials to style a room.'); else setTileRoomPrompt((cur) => cur ? { ...cur, error: 'AI choose works with up to 6 selected materials.' } : cur); return; } const titles = autoAssign ? (picks.map((g) => g.title || 'Item').join(' · ') || 'AI room styling') : (tileRoomTitle(picks, assignments) || 'Styled room'); setTileRoomPrompt((cur) => cur ? { ...cur, error: '' } : cur); setError(''); setGenerating(true); setCombinedResult({ status: 'pending', titles, kind: 'tile-room', ai: autoAssign }); await _StyleKroGen.runTryon({ streamSSE, url: '/garments/tile-room/stream', token, body: JSON.stringify(autoAssign ? { auto_assign: true, garment_ids: picks.map((g) => g.id), ...(cameraRoomPhoto ? { room_photo_b64: cameraRoomPhoto } : {}), } : { placements, ...(cameraRoomPhoto ? { room_photo_b64: cameraRoomPhoto } : {}), }), headers: { 'Content-Type': 'application/json' }, errorLabel: 'Room styling failed', onUpdate: ({ status, image_url, pending_text, message_id, error }) => { setCombinedResult((cur) => { if (status === 'error') return { status: 'error', titles, kind: 'tile-room', ai: autoAssign, error }; const next = { status, titles, kind: 'tile-room', ai: autoAssign }; if (pending_text !== undefined) next.pending_text = pending_text; if (image_url !== undefined) next.image_url = imgUrl(image_url); next.message_id = message_id || cur?.message_id; return next; }); }, }); setGenerating(false); clearSelection(); } async function generateTileRoomInline() { if (generating) return; const ids = Object.values(tileAssignments).filter(Boolean); if (ids.length === 0) { setError('Add a tile to at least one surface to style the room.'); return; } const picks = garments.filter((g) => ids.includes(g.id)); setError(''); setStudio360(false); setSheet360MsgId(null); await confirmTileRoomTryon(false, picks, tileAssignments); } async function generateTailorSelections() { if (selected.size === 0 || generating) return; const picks = garments.filter((g) => selected.has(g.id)); const design = picks.find((g) => garmentKind(g) === 'design'); const assignedFabricIds = selectedTailorSlots.map((slot) => tailorFabricAssignments[slot.key]).filter(Boolean); const fabrics = (selectedTailorSlots.length ? assignedFabricIds : picks.filter((g) => garmentKind(g) === 'fabric').map((g) => g.id)) .map((fid) => garments.find((g) => g.id === fid)) .filter(Boolean); if (!design || fabrics.length === 0) { setError(t('shop.pickDesignFabric')); return; } // Multi-piece outfit (e.g. Coat + Pant + Shirt): let the buyer assign a // fabric to each piece, then build ONE combined outfit. Single-piece // options keep the per-fabric behaviour below. const pieceSlots = selectedTailorSlots; if (pieceSlots.length >= 2) { const missing = pieceSlots.filter((slot) => !tailorFabricAssignments[slot.key]); if (missing.length) { setError(`Pick fabric for ${missing.map((slot) => slot.label).join(', ')}.`); setActiveTailorFabricSlot(missing[0].key); scrollToTailorRef(tailorFabricsRef); return; } const assignments = Object.fromEntries(pieceSlots.map((slot) => [slot.key, tailorFabricAssignments[slot.key]])); setError(''); await runPieceFabricTryon({ design, slots: pieceSlots, fabrics, assignments, piecesPreset: selectedTailorPreset, error: '' }); return; } // Single-piece outfit: still render the finished look in the "Outfit // setup" panel (step 2) — the same place the multi-piece flow uses — // instead of flipping the design/fabric card in the rails below. const fabric = fabrics[0]; if (!(await ensureProfilePhoto())) return; const slotLabel = selectedTailorSlots[0]?.label || selectedTailorLabel || 'Outfit'; const slotKey = selectedTailorSlots[0]?.key || 'outfit'; const assignmentSummary = [{ slot: { key: slotKey, label: slotLabel }, fabric_title: fabric.title || 'Fabric', fabric_url: fabric.photo_url || '' }]; const titles = `${slotLabel}: ${fabric.title || 'Fabric'}`; setError(''); setTailorSetupFlipped(false); scrollToTailorRef(tailorSetupRef); const baseTailor = { titles, kind: 'tailor-outfit', design_title: design.title, original_url: imgUrl(design.photo_url), assignments: assignmentSummary }; setCombinedResult({ status: 'pending', ...baseTailor }); setGenerating(true); await _StyleKroGen.runTryon({ streamSSE, url: '/garments/tailor-tryon/stream', token, body: JSON.stringify({ design_id: design.id, fabric_id: fabric.id, pieces: selectedTailorPreset || undefined }), headers: { 'Content-Type': 'application/json' }, onUpdate: ({ status, image_url, pending_text, message_id, error }) => { setCombinedResult((cur) => { const next = { ...(cur || {}), ...baseTailor, status }; if (status === 'error') { next.error = error; return next; } if (pending_text !== undefined) next.pending_text = pending_text; if (image_url !== undefined) next.image_url = imgUrl(image_url); if (message_id || cur?.message_id) next.message_id = message_id || cur?.message_id; return next; }); }, }); setGenerating(false); clearSelection(); } // Generate ONE combined outfit where each garment piece uses its assigned // fabric (driven by the pieceFabricPrompt mapping overlay). async function runPieceFabricTryon(promptOverride = null) { const prompt = promptOverride || pieceFabricPrompt; if (!prompt || generating) return; const { design, slots, fabrics, assignments, piecesPreset } = prompt; if (slots.some((s) => !assignments[s.key])) { setPieceFabricPrompt((cur) => (cur ? { ...cur, error: 'Pick a fabric for every piece.' } : cur)); return; } if (!(await ensureProfilePhoto())) return; const byId = Object.fromEntries(fabrics.map((g) => [g.id, g])); const piece_fabrics = slots.map((s) => ({ piece: s.key, fabric_id: assignments[s.key] })); const assignmentSummary = slots.map((s) => { const fabric = byId[assignments[s.key]] || null; return { slot: s, fabric_title: fabric?.title || 'Fabric', fabric_url: fabric?.photo_url || '' }; }); const titles = slots .map((s) => `${s.label}: ${(byId[assignments[s.key]] || {}).title || 'Fabric'}`) .join(' · '); setPieceFabricPrompt(null); setTailorSetupFlipped(false); scrollToTailorRef(tailorSetupRef); const baseTailor = { titles, kind: 'tailor-outfit', design_title: design.title, original_url: imgUrl(design.photo_url), assignments: assignmentSummary }; setCombinedResult({ status: 'pending', ...baseTailor }); setGenerating(true); try { await _StyleKroGen.runTryon({ streamSSE, url: '/garments/tailor-tryon/stream', token, body: JSON.stringify({ design_id: design.id, pieces: piecesPreset || selectedTailorPreset || undefined, piece_fabrics }), headers: { 'Content-Type': 'application/json' }, onUpdate: ({ status, image_url, pending_text, message_id, error }) => { setCombinedResult((cur) => { const next = { ...(cur || {}), ...baseTailor, status }; if (status === 'error') { next.error = error; return next; } if (pending_text !== undefined) next.pending_text = pending_text; if (image_url !== undefined) next.image_url = imgUrl(image_url); if (message_id || cur?.message_id) next.message_id = message_id || cur?.message_id; return next; }); }, }); } finally { setGenerating(false); clearSelection(); } } async function generateSeparate() { if (selected.size === 0 || generating) return; const picks = garments.filter((g) => selected.has(g.id)); setGenerating(true); await runTryon(picks); setGenerating(false); clearSelection(); } async function generateSelected() { if (selected.size === 0 || generating) return; if (!(await ensureProfilePhoto())) return; if (isTailorShop) return generateTailorSelections(); const picks = garments.filter((g) => selected.has(g.id)); if (isTileShop) { await promptTileRoom(picks); return; } if (picks.length === 1) return generateSeparate(); return generateCombined(); } async function generateOne(g) { if (!g || generating) return; if (!(await ensureProfilePhoto())) return; const r = results[g.id]; if (r && r.status === 'pending') return; setGenerating(true); await runTryon([g]); setGenerating(false); } // Normalise + stash the buyer's uploaded photo (HEIC->JPEG + downscale), keep // the upload-ready Blob in a ref and an object URL for the preview. async function onTattooFileChange(e) { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return; const Tattoo = (typeof window !== 'undefined' && window.ShopCategories?.byKey?.tattoo) || null; try { const blob = Tattoo && Tattoo.prepareTattooPhoto ? await Tattoo.prepareTattooPhoto(f) : f; if (!blob) throw new Error('empty photo'); tattooPhotoFileRef.current = blob; setTattooPhotoUrl((prev) => { if (prev) { try { URL.revokeObjectURL(prev); } catch (_) {} } return URL.createObjectURL(blob); }); // A new photo invalidates any previous result. setCombinedResult(null); setError(''); } catch (err) { console.error('Tattoo photo load failed', err); alert("Couldn't read that photo. Try a JPG or PNG."); } } function clearTattooPhoto() { tattooPhotoFileRef.current = null; setTattooPhotoUrl((prev) => { if (prev) { try { URL.revokeObjectURL(prev); } catch (_) {} } return null; }); if (tattooFileInputRef.current) tattooFileInputRef.current.value = ''; } async function getTattooProfilePhotoFile() { let profilePhotoUrl = profile?.photo_url || ''; if (!profilePhotoUrl) { if (!(await ensureProfilePhoto())) return null; try { const latestProfile = await apiCall('/profiles/me'); profilePhotoUrl = latestProfile?.photo_url || ''; } catch (_) {} } if (!profilePhotoUrl) return null; const url = imgUrl(bust(profilePhotoUrl, photoVersion)); const headers = token ? { Authorization: `Bearer ${token}` } : undefined; const res = await fetch(url, { headers }); if (!res.ok) throw new Error('Could not read your profile photo.'); const blob = await res.blob(); const file = new File([blob], 'profile-photo.jpg', { type: blob.type || 'image/jpeg' }); const Tattoo = (typeof window !== 'undefined' && window.ShopCategories?.byKey?.tattoo) || null; return Tattoo && Tattoo.prepareTattooPhoto ? await Tattoo.prepareTattooPhoto(file) : file; } function tatPointerDown(mode) { return (e) => { if (e.pointerType === 'mouse' && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const rect = tatCanvasRef.current && tatCanvasRef.current.getBoundingClientRect(); if (!rect) return; const start = tatXfRef.current; const cx = rect.left + rect.width * (start.xPct / 100); const cy = rect.top + rect.height * (start.yPct / 100); tatGesture.current = { mode, rect, start, sx: e.clientX, sy: e.clientY, cx, cy, startAngle: Math.atan2(e.clientY - cy, e.clientX - cx), }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} }; } function tatPointerMove(e) { const gobj = tatGesture.current; if (!gobj) return; e.preventDefault(); const { mode, rect, start, sx, sy, cx, cy, startAngle } = gobj; if (mode === 'move') { const dxPct = ((e.clientX - sx) / rect.width) * 100; const dyPct = ((e.clientY - sy) / rect.height) * 100; setTat({ ...start, xPct: Math.max(2, Math.min(98, start.xPct + dxPct)), yPct: Math.max(2, Math.min(98, start.yPct + dyPct)) }); } else if (mode === 'scale') { const nat = tatNatRef.current; const aspect = nat.w ? nat.h / nat.w : 1; const dist = Math.hypot(e.clientX - cx, e.clientY - cy); const wPx = (2 * dist) / Math.sqrt(1 + aspect * aspect); setTat({ ...start, wPct: Math.max(6, Math.min(100, (wPx / rect.width) * 100)) }); } else if (mode === 'rotate') { const ang = Math.atan2(e.clientY - cy, e.clientX - cx); const deg = ((ang - startAngle) * 180) / Math.PI; setTat({ ...start, rot: start.rot + deg }); } } function tatPointerEnd(e) { if (tatGesture.current) { try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {} } tatGesture.current = null; } function loadImageEl(src) { return new Promise((resolve, reject) => { const im = new Image(); im.onload = () => resolve(im); im.onerror = () => reject(new Error('image load failed')); im.src = src; }); } // The image used for the tattoo design everywhere in the studio: the seller // can publish a background-removed cut-out (transparent PNG) and toggle // whether buyers see it; when active we prefer it so the design overlays and // composites cleanly without a white box. function tattooDesignSrc(g) { return (g && g.cutout_active && g.cutout_url) ? g.cutout_url : (g ? g.photo_url : ''); } async function fetchTattooDesignBlob(g) { const url = imgUrl(tattooDesignSrc(g)); const headers = token ? { Authorization: `Bearer ${token}` } : undefined; const res = await fetch(url, { headers }); if (!res.ok) throw new Error('Could not load the tattoo design.'); return await res.blob(); } // Flatten the buyer photo + the positioned tattoo into one 4:5 image that // matches the on-screen preview (the canvas uses object-fit: cover), so the // AI receives the design exactly where it was dragged. Returns a JPEG Blob. async function compositeTattooPhoto(personBlob, designBlob, xf) { const personUrl = URL.createObjectURL(personBlob); const designUrl = URL.createObjectURL(designBlob); try { const [pimg, dimg] = await Promise.all([loadImageEl(personUrl), loadImageEl(designUrl)]); const targetAR = 4 / 5; const pw = pimg.naturalWidth || 1; const ph = pimg.naturalHeight || 1; let sx, sy, sw, sh; if (pw / ph > targetAR) { sh = ph; sw = ph * targetAR; sx = (pw - sw) / 2; sy = 0; } else { sw = pw; sh = pw / targetAR; sx = 0; sy = (ph - sh) / 2; } const canvasW = Math.min(1080, Math.round(sw)); const canvasH = Math.round(canvasW / targetAR); const cvs = document.createElement('canvas'); cvs.width = canvasW; cvs.height = canvasH; const ctx = cvs.getContext('2d'); ctx.drawImage(pimg, sx, sy, sw, sh, 0, 0, canvasW, canvasH); const tw = (xf.wPct / 100) * canvasW; const dAspect = dimg.naturalWidth ? dimg.naturalHeight / dimg.naturalWidth : 1; const th = tw * dAspect; const tcx = (xf.xPct / 100) * canvasW; const tcy = (xf.yPct / 100) * canvasH; ctx.save(); ctx.translate(tcx, tcy); ctx.rotate((xf.rot * Math.PI) / 180); ctx.drawImage(dimg, -tw / 2, -th / 2, tw, th); ctx.restore(); return await new Promise((resolve) => cvs.toBlob((b) => resolve(b), 'image/jpeg', 0.92)); } finally { URL.revokeObjectURL(personUrl); URL.revokeObjectURL(designUrl); } } async function generateTattooInline() { if (generating) return; const g = garments.find((x) => x.id === tattooDesignId); if (!g) { setError('Pick a tattoo design first.'); return; } let photoFile = tattooPhotoFileRef.current; if (!photoFile) { try { photoFile = await getTattooProfilePhotoFile(); } catch (err) { console.error('Tattoo profile photo fallback failed', err); setError((err && err.message) || 'Could not read your profile photo.'); return; } if (!photoFile) return; } const titles = g.title || 'Tattoo'; setError(''); setGenerating(true); setStudio360(false); setSheet360MsgId(null); setCombinedResult({ status: 'pending', titles, kind: 'tattoo' }); const onUpdate = (gg, { status, image_url, pending_text, message_id, error }) => { setCombinedResult((cur) => { if (status === 'error') return { status: 'error', titles, kind: 'tattoo', error }; const next = { status, titles, kind: 'tattoo' }; if (pending_text !== undefined) next.pending_text = pending_text; if (image_url !== undefined) next.image_url = imgUrl(image_url); next.message_id = message_id || cur?.message_id; return next; }); }; // Bake the positioned/resized/rotated tattoo into the photo so the AI sees // it exactly where the buyer placed it; fall back to the plain photo if the // client-side composite fails for any reason. let effectiveNote = ''; try { const designBlob = await fetchTattooDesignBlob(g); const composited = await compositeTattooPhoto(photoFile, designBlob, tatXfRef.current); if (composited) { photoFile = composited; effectiveNote = TATTOO_COMPOSITE_NOTE; } } catch (err) { console.error('Tattoo composite failed; sending original photo', err); } const Tattoo = (typeof window !== 'undefined' && window.ShopCategories?.byKey?.tattoo) || null; if (Tattoo && Tattoo.runTattooTryon) { await Tattoo.runTattooTryon({ picks: [g], streamSSE, token, photoFile, note: effectiveNote, src: 'tattooStudio', errorLabel: 'Tattoo try-on failed', onUpdate, }); } setGenerating(false); } function tailorCardSubtitle(g) { const r = results[g.id]; const kind = garmentKind(g); if (kind === 'design') return r?.fabric_title ? `Styled with ${r.fabric_title}` : (g.description || g.subcategory || g.category_group || 'Select this design'); if (r?.design_title) return `Styled with ${r.design_title}`; return g.description || g.color || g.subcategory || 'Select this fabric'; } function renderTailorGarmentCard(g) { const isSel = selected.has(g.id); const r = results[g.id]; const kind = garmentKind(g); const showFlip = r && (r.status === 'done' || r.status === 'fast'); const upgrading = r && r.status === 'fast'; const isFlipped = showFlip && flipped.has(g.id); return (
{ if (!showFlip) { if (isTailorShop) toggle(g.id); else navigate(`/shops/${id}/items/${g.id}`); } }}> {showFlip ? (
{ e.stopPropagation(); setFlipped((cur) => { const next = new Set(cur); if (next.has(g.id)) next.delete(g.id); else next.add(g.id); return next; }); }} title={t('tailor.tapFlip')} >
{`${g.title} {upgrading ? t('tailor.quickPreview') : t('tailor.tapFlip')}
{g.title} {t('tailor.original')}
{upgrading && (
)}
) : ( <> {g.title} {r && r.status === 'pending' && (
)} {r && r.status === 'error' && (
⚠ {r.error}
)} )}
toggle(g.id)}>{tailorCardSubtitle(g)}
); } function renderGarmentCard(g) { const isSel = selected.has(g.id); const r = results[g.id]; const showFlip = r && (r.status === 'done' || r.status === 'fast'); const upgrading = r && r.status === 'fast'; const isFlipped = showFlip && flipped.has(g.id); const kind = garmentKind(g); return (
navigate(`/shops/${id}/items/${g.id}`)}> {showFlip ? (
{ e.stopPropagation(); setFlipped((cur) => { const next = new Set(cur); if (next.has(g.id)) next.delete(g.id); else next.add(g.id); return next; }); }} title={t('tailor.tapFlip')} >
{`${g.title} {upgrading ? t('tailor.quickPreview') : t('tailor.tapFlip')}
{g.title} {t('tailor.original')}
{upgrading && (
)}
) : ( <> {g.title} {r && r.status === 'pending' && (
)} {r && r.status === 'error' && (
⚠ {r.error}
)} )}
{((kind || g.subcategory || g.category_group) || g.color || r?.design_title) && (
{[ assetKindLabel(kind), g.subcategory || g.category_group, g.color, isTailorShop && kind === 'fabric' && r?.design_title ? `with ${r.design_title}` : null, ].filter(Boolean).join(' · ')}
)} {g.price &&
{g.price}
}
); } return (
{loading ?

Loading…

: ( shop ? (() => { // Derive filter state. const q = search.trim().toLowerCase(); const groupCounts = makeCountMap(garments, 'category_group', { asMap: true, skipEmpty: true }); const groupsSorted = Array.from(groupCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([k]) => k); const primaryGroups = groupsSorted.slice(0, 5); const hasMoreGroups = groupsSorted.length > 5; const subCounts = makeCountMap( filterGroup ? garments.filter((g) => g.category_group === filterGroup) : garments, 'subcategory', { asMap: true, skipEmpty: true } ); const subs = Array.from(subCounts.keys()).sort(); const list = garments.filter((g) => { if (isTailorShop) return true; if (filterGroup && g.category_group !== filterGroup) return false; if (filterSub && g.subcategory !== filterSub) return false; if (!q) return true; return ( (g.title || '').toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q) || String(g.price || '').toLowerCase().includes(q) || (g.asset_kind || '').toLowerCase().includes(q) || (g.subcategory || '').toLowerCase().includes(q) || (g.category_group || '').toLowerCase().includes(q) || (g.item_code || '').toLowerCase().includes(q) || (g.color || '').toLowerCase().includes(q) ); }); const selectedItems = garments.filter((g) => selected.has(g.id)); const selectedDesign = selectedItems.find((g) => garmentKind(g) === 'design') || null; const selectedFabrics = selectedItems.filter((g) => garmentKind(g) === 'fabric'); const tailorOutfitResult = combinedResult && combinedResult.kind === 'tailor-outfit' ? combinedResult : null; const showTailorSetupEmpty = !selectedDesign && !tailorOutfitResult && selectedTailorSlots.length === 0; const tailorPreviewAssignments = Array.isArray(tailorOutfitResult?.assignments) && tailorOutfitResult.assignments.length ? tailorOutfitResult.assignments.map((a) => ({ slot: a.slot, fabric: a.fabric_url ? { title: a.fabric_title, photo_url: a.fabric_url } : null })) : selectedTailorSlots.length ? selectedTailorSlots.map((slot) => ({ slot, fabric: tailorFabricAssignments[slot.key] ? garments.find((g) => g.id === tailorFabricAssignments[slot.key]) || null : null, })) : selectedFabrics.slice(0, 3).map((fabric, idx) => ({ slot: { key: `fabric_${idx}`, label: idx === 0 ? 'Fabric' : `Fabric ${idx + 1}` }, fabric })); const matchesTailorSearch = (g, term) => { const needle = term.trim().toLowerCase(); if (!needle) return true; return ( (g.title || '').toLowerCase().includes(needle) || (g.description || '').toLowerCase().includes(needle) || (g.subcategory || '').toLowerCase().includes(needle) || (g.category_group || '').toLowerCase().includes(needle) || (g.item_code || '').toLowerCase().includes(needle) || (g.color || '').toLowerCase().includes(needle) ); }; const designList = isTailorShop ? list.filter((g) => garmentKind(g) === 'design' && matchesTailorSearch(g, tailorDesignSearch)) : []; const fabricList = isTailorShop ? list.filter((g) => garmentKind(g) === 'fabric' && matchesTailorSearch(g, tailorFabricSearch)) : []; // Icons keyed by lowercase group name (fall back to a generic icon). const GROUP_ICONS = { topwear: (), tops: (), dresses: (), bottoms: (), shoes: (), bags: (), accessories: (), }; const iconFor = (label) => GROUP_ICONS[(label || '').toLowerCase()] || ( ); return ( <> {!isTailorShop && !isTileShop && (
setSearch(e.target.value)} aria-label={t('shop.searchPlaceholder')} />
)} {/* Shop hero card (compact by default, expandable) */}
{(shop.name || '?').trim().charAt(0).toUpperCase()}
{t('shop.eyebrow')}

{shop.name}

{shopExpanded && ( <> {shop.location && ( )} {shop.phone && ( )}
{shop.category && {shopCategoryLabel(shop.category)}} {garments.length} piece{garments.length === 1 ? '' : 's'}
{shop.description &&

{shop.description}

} )}
{shopExpanded && ( )}
{error &&

{error}

} {garments.length === 0 ? (
🪡

{t('shop.noPiecesYet')}

{t('shop.checkBackSoon')}

) : ( <> {!isTailorShop && filterOpen && (
setFilterOpen(false)}>
e.stopPropagation()}>

{t('filter.title')}

{t('filter.category')}
{groupsSorted.map((grp) => ( ))}
{filterGroup && subs.length > 0 && (
{t('filter.subcategory')}
{subs.map((sub) => ( ))}
)}
)} {list.length === 0 ? (

{t('shop.noMatchingPieces')}

) : isTailorShop ? (

1. {t('tailor.chooseType')}

{tailorSlotChoices.length} {tailorSlotChoices.length === 1 ? t('tailor.pieceLabel') : t('tailor.piecesLabel')}
{visibleTailorSlots.map((slot) => ( ))} {tailorMoreSlots.length > 0 && ( )}

2. {t('tailor.outfitSetup')}

{t('tailor.customizePieces')}

{showTailorSetupEmpty ? (
{t('tailor.selectDesignFabric')} {t('tailor.selectHint')}
) : ( <>
{tailorOutfitResult?.status === 'pending' && (
)} {tailorOutfitResult?.status === 'error' && (
⚠ {tailorOutfitResult.error}
)} {(tailorOutfitResult?.status === 'fast' || tailorOutfitResult?.status === 'done') && tailorOutfitResult.image_url ? (
{ e.stopPropagation(); setTailorSetupFlipped((v) => !v); }} title={t('tailor.tapFlip')} >
Generated tailor outfit {tailorOutfitResult.status === 'fast' ? t('tailor.quickPreview') : t('tailor.tapFlip')}
Original design {t('tailor.original')}
{tailorOutfitResult.status === 'fast' && (
)}
) : selectedDesign && !tailorOutfitResult?.status ? ( <> {selectedDesign.title ) : !tailorOutfitResult?.status ? ( ) : null}
{(tailorPreviewAssignments.length ? tailorPreviewAssignments : [{ slot: { key: 'empty', label: t('tailor.fabricWord') }, fabric: null }]).map(({ slot, fabric }) => ( ))}
)}

3. {t('tailor.exploreDesigns')}

{designList.length} {designList.length === 1 ? t('tailor.designLabel') : t('tailor.designsLabel')}
{designList.length === 0 ? (

{t('shop.noMatchingDesigns')}

) : (
{designList.map(renderTailorGarmentCard)}
)}

4. {t('tailor.fabricCollection')}

{fabricList.length} {fabricList.length === 1 ? t('tailor.fabricLabel') : t('tailor.fabricsLabel')}
{fabricList.length === 0 ? (

{t('shop.noMatchingFabrics')}

) : (
{fabricList.map(renderTailorGarmentCard)}
)}
) : isTileShop ? (() => { const q2 = tileRoomSearch.trim().toLowerCase(); const tileCategoryOf = (g) => (g.category_group || '').trim(); const tileCats = ['All', ...garments.reduce((acc, g) => { const c = tileCategoryOf(g); if (c && !acc.includes(c)) acc.push(c); return acc; }, [])]; const tileList = garments.filter((g) => { if (tileCat !== 'All' && tileCategoryOf(g) !== tileCat) return false; if (!q2) return true; return ( (g.title || '').toLowerCase().includes(q2) || (g.subcategory || '').toLowerCase().includes(q2) || (g.category_group || '').toLowerCase().includes(q2) || (g.color || '').toLowerCase().includes(q2) || (g.asset_kind || '').toLowerCase().includes(q2) ); }); const assignedCount = Object.values(tileAssignments).filter(Boolean).length; const gen = combinedResult && combinedResult.kind === 'tile-room' ? combinedResult : null; const genBusy = gen && (gen.status === 'pending' || gen.status === 'fast'); const genShot = gen && (gen.status === 'fast' || gen.status === 'done') ? gen.image_url : null; const roomBase = cameraRoomPhoto ? cameraRoomPhoto : (profile?.photo_url ? bust(imgUrl(profile.photo_url), photoVersion) : null); const SURFACE_ICON = { floor: (), front_wall: (), left_wall: (), right_wall: (), }; const activeSurfaceLabel = (TILE_ROOM_SURFACES.find((s) => s.value === activeTileSurface) || {}).label || ''; return (

StyleKro Room

{TILE_ROOM_SURFACES.map((s) => ( ))}
{studio360 ? (
) : genShot ? ( Room styled with selected materials setLightbox(genShot)} style={{ cursor: 'zoom-in' }} /> ) : roomBase ? ( <> Your room { if (node && node.complete && node.naturalWidth > 0) setLoadedRoomSrc(roomBase); }} onLoad={() => setLoadedRoomSrc(roomBase)} onError={() => setLoadedRoomSrc(roomBase)} onClick={() => setLightbox(roomBase)} style={{ cursor: 'zoom-in' }} /> {loadedRoomSrc !== roomBase && (
)} ) : !authReady ? (
) : (
Add a room photo Set your room photo in Profile to preview materials here.
)} {gen && gen.status === 'pending' && (
)} {gen && gen.status === 'error' && (
⚠ {gen.error}
)} {gen && gen.status === 'fast' && (
)} {!gen && roomBase && TILE_ROOM_SURFACES.map((s) => ( ))} {!gen && ( )} {!gen && cameraRoomPhoto && ( )} { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return; try { const normalized = await prepareCameraImage(f); const url = await readFileAsDataURL(normalized); if (url) setCameraRoomPhoto(url); } catch (err) { console.error('Room photo load failed', err); alert("Couldn't read that photo. Try a JPG or PNG."); } }} /> {gen && gen.status === 'done' && !studio360 && (
{gen.message_id && ( )} {gen.image_url && ( )}
)}

Applied Materials

{assignedCount > 0 && ( )}
{TILE_ROOM_SURFACES.map((s) => { const gid = tileAssignments[s.value]; const item = gid ? garments.find((g) => g.id === gid) : null; const active = activeTileSurface === s.value; return ( ); })}
{tileCats.map((c) => ( ))}
{tileList.length === 0 ? (

{t('shop.noMatchingPieces')}

) : (
{tileList.map((g) => { const onActive = tileAssignments[activeTileSurface] === g.id; return (
{g.title || 'Material'}
{g.price ? (
₹{g.price} / sqft
) : tileCategoryOf(g) ? (
{tileCategoryOf(g)}
) : null}
); })}
)}
{!genBusy && assignedCount > 0 && ( )}
); })() : isTattooShop ? (() => { const gen = combinedResult && combinedResult.kind === 'tattoo' ? combinedResult : null; const genBusy = gen && (gen.status === 'pending' || gen.status === 'fast'); const genShot = gen && (gen.status === 'fast' || gen.status === 'done') ? gen.image_url : null; const profileTattooPhotoUrl = profile?.photo_url ? imgUrl(bust(profile.photo_url, photoVersion)) : null; const canvasImg = genShot || tattooPhotoUrl || profileTattooPhotoUrl; const tattooList = list; const tatDesign = tattooList.find((x) => x.id === tattooDesignId) || null; const tatDesignUrl = tatDesign ? imgUrl(tattooDesignSrc(tatDesign)) : null; const tatOverlayOn = !gen && !studio360 && !!tattooDesignId && !!canvasImg && !!tatDesignUrl; return (
{studio360 ? (
) : canvasImg ? ( Tattoo preview setLightbox(canvasImg)} /> ) : (
)} {tatOverlayOn && (
{ const im = e.currentTarget; if (im.naturalWidth) tatNatRef.current = { w: im.naturalWidth, h: im.naturalHeight }; }} />
)} {!gen && !studio360 && ( )} {!gen && !studio360 && tattooPhotoUrl && ( )} {gen && gen.status === 'pending' && (
)} {gen && gen.status === 'error' && (
⚠ {gen.error}
)} {gen && gen.status === 'fast' && (
)} {gen && gen.status === 'done' && !studio360 && (
{gen.message_id && ( )} {gen.image_url && ( )}
)}

Pick a tattoo

{tattooList.length === 0 ? (

{t('shop.noMatchingPieces')}

) : (
{tattooList.map((g) => { const onActive = tattooDesignId === g.id; return ( ); })}
)}
{tattooDesignId && canvasImg && (

Drag to move • corner handle to resize • top handle to rotate, then tap StyleKro.

)} {!genBusy && tattooDesignId && ( )}
); })() : (
{list.map(renderGarmentCard)}
)} )} {/* Floating action bar */} {selected.size > 0 && (() => { const cats = selectedItems.map((g) => g.category_group || '').filter(Boolean); const allUniqueCats = cats.length === selectedItems.length && new Set(cats).size === cats.length; const canCombine = !isTailorShop && !isTileShop && selected.size >= 2 && allUniqueCats; const canTileRoom = isTileShop && selected.size >= 1; const canTailorGenerate = isTailorShop && !!selectedDesign && selectedFabrics.length > 0; return (
{!isTailorShop && {selected.size}} {isTailorShop ? ( <> {selectedTailorLabel ? `${selectedTailorLabel} · ` : ''} {selectedDesign ? '1 design selected' : 'Pick 1 design'} {` · ${selectedFabrics.length} fabric${selectedFabrics.length === 1 ? '' : 's'}`} ) : ( <> {canTileRoom && ( )} {canCombine && ( )} )}
); })()} ); })() :

{t('shop.notFound')}

)} {combinedResult && combinedResult.kind !== 'tailor-outfit' && combinedResult.kind !== 'tile-room' && combinedResult.kind !== 'tattoo' && (
setCombinedResult(null)}>
e.stopPropagation()}>
{combinedResult.kind === 'tile-room' ? 'Your styled room' : 'Your combined look'}
{combinedResult.titles}
{combinedResult.status === 'pending' && } {(combinedResult.status === 'fast' || combinedResult.status === 'done') && ( <> {combinedResult.kind setLightbox(combinedResult.image_url)} style={{ cursor: 'zoom-in' }} /> {combinedResult.status === 'fast' && (
)} )} {combinedResult.status === 'error' && (
⚠ {combinedResult.error}
)}
{combinedResult.status === 'done' && combinedResult.message_id && (
)}
)}
{lightbox && (
setLightbox(null)}> { e.stopPropagation(); setLightbox(null); setSheet360MsgId(combinedResult.message_id); } : null} />
)} setSheet360MsgId(null)} /> {tileRoomPrompt && (() => { const assignedItems = TILE_ROOM_SURFACES .map((s) => tileRoomPrompt.picks.find((g) => g.id === tileRoomPrompt.assignments[s.value])) .filter(Boolean); const heroItem = assignedItems[0] || tileRoomPrompt.picks[0] || null; const SURFACE_ICONS = { floor: (), front_wall: (), left_wall: (), right_wall: (), }; const resetAll = () => setTileRoomPrompt((cur) => ({ ...cur, error: '', assignments: TILE_ROOM_SURFACES.reduce((m, s) => ({ ...m, [s.value]: '' }), {}), })); return (
setTileRoomPrompt(null)}>
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Style your room">

{t('room.styleYour')} {t('room.room')}

{(() => { const gen = combinedResult && combinedResult.kind === 'tile-room' ? combinedResult : null; const showResult = gen && gen.image_url && (gen.status === 'fast' || gen.status === 'done'); if (showResult) { return Styled room; } if (profile?.photo_url) { return Your photo; } return
{TILE_ROOM_SURFACES.map((surface) => { const selectedId = tileRoomPrompt.assignments[surface.value] || ''; const item = tileRoomPrompt.picks.find((g) => g.id === selectedId) || null; return ( ); })}
{tileRoomPrompt.error &&
⚠ {tileRoomPrompt.error}
}
confirmTileRoomTryon(false)} />
); })()} {false && pieceFabricPrompt && (
setPieceFabricPrompt(null)}>
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Assign a fabric to each piece">

Customize your outfit

Choose fabric for each piece

{pieceFabricPrompt.design?.title || 'Selected design'}

{pieceFabricPrompt.slots.map((slot) => { const selectedId = pieceFabricPrompt.assignments[slot.key] || ''; const item = pieceFabricPrompt.fabrics.find((g) => g.id === selectedId) || null; return ( ); })}
{pieceFabricPrompt.error &&
{pieceFabricPrompt.error}
}
)}
); } function ShopSettingsPage() { const { apiCall, t } = useAuth(); const confirmAction = useConfirmDialog(); const navigate = useNavigate(); const [shop, setShop] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [savedFlash, setSavedFlash] = useState(false); const [linkCopied, setLinkCopied] = useState(false); const shareLink = shop ? `${window.location.protocol}//${window.location.host}${window.location.pathname}#/shops/${shop.id}` : ''; async function copyShareLink() { if (!shareLink) return; try { await navigator.clipboard.writeText(shareLink); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 1800); } catch { try { const ta = document.createElement('textarea'); ta.value = shareLink; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 1800); } catch {} } } async function shareShop() { if (!shareLink) return; const data = { title: shop?.name ? `${shop.name} · StyleKro` : 'StyleKro Shop', text: shop?.name ? `Explore ${shop.name} on StyleKro and try on any piece with AI before you buy.` : 'Explore this shop on StyleKro and try on any piece with AI before you buy.', url: shareLink, }; if (navigator.share) { try { await navigator.share(data); return; } catch {} } copyShareLink(); } const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [location, setLocation] = useState(''); const [phone, setPhone] = useState(''); const [category, setCategory] = useState(''); const [isPublic, setIsPublic] = useState(true); const [categories, setCategories] = useState([]); // Tailor shops: which garment-piece options the buyer can request. const [tailorPieces, setTailorPieces] = useState([]); // Tailor shops: gender focus ('' = both/all, 'men', 'women') filters the // stitching-option groups the seller (and buyer) sees. const [shopGender, setShopGender] = useState(''); // Banner upload const bannerInputRef = useRef(null); const [bannerVersion, setBannerVersion] = useState(0); const [bannerBusy, setBannerBusy] = useState(false); async function uploadBannerFile(file) { if (!file || !shop) return; setBannerBusy(true); setError(''); try { const fd = new FormData(); fd.append('photo', file); const s = await apiCall(`/shops/${shop.id}/banner`, { method: 'POST', body: fd }); setBannerVersion(Date.now()); setShop(s); } catch (err) { setError(err.message || 'Could not upload banner.'); } finally { setBannerBusy(false); } } async function onBannerPick(e) { const file = e.target.files && e.target.files[0]; e.target.value = ''; await uploadBannerFile(file); } function pickBannerNative() { if (bannerBusy) return; nativePickFiles(false) .then((files) => { if (files && files[0]) uploadBannerFile(files[0]); }) .catch((err) => setError(err && err.message ? err.message : 'Could not pick photo.')); } async function removeBanner() { if (!shop || !shop.has_banner) return; if (!(await confirmAction(t('shop.confirmRemoveBanner'), { confirmLabel: t('common.delete'), tone: 'danger' }))) return; setBannerBusy(true); setError(''); try { const s = await apiCall(`/shops/${shop.id}/banner`, { method: 'DELETE' }); setBannerVersion(Date.now()); setShop(s); } catch (err) { setError(err.message || 'Could not remove banner.'); } finally { setBannerBusy(false); } } // Inventory snapshot for stats + bulk uploader const [garments, setGarments] = useState([]); const bulkInputRef = useRef(null); const [queue, setQueue] = useState([]); const [uploading, setUploading] = useState(false); // Google Places Autocomplete on the Location input. const locationInputRef = useRef(null); const autocompleteRef = useRef(null); const autocompleteNodeRef = useRef(null); const nameRef = useRef(''); const descriptionRef = useRef(''); const phoneRef = useRef(''); const categoryRef = useRef(''); const [placesLoading, setPlacesLoading] = useState(false); const [placesError, setPlacesError] = useState(''); const [locating, setLocating] = useState(false); useEffect(() => { nameRef.current = name; }, [name]); useEffect(() => { descriptionRef.current = description; }, [description]); useEffect(() => { phoneRef.current = phone; }, [phone]); useEffect(() => { categoryRef.current = category; }, [category]); async function locateMe() { setPlacesError(''); if (!navigator.geolocation) { setPlacesError('Geolocation is not supported by this browser.'); return; } setLocating(true); try { const pos = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 12000, maximumAge: 60000, }); }); const { latitude: lat, longitude: lng } = pos.coords; const google = await loadGoogleMaps(); const geocoder = new google.maps.Geocoder(); const result = await new Promise((resolve, reject) => { geocoder.geocode({ location: { lat, lng } }, (results, status) => { if (status === 'OK' && results && results[0]) resolve(results); else reject(new Error(`Geocoder failed: ${status}`)); }); }); // Prefer an establishment/POI if Google returned one (closest shop), else street address. const poi = result.find((r) => (r.types || []).some((t) => t === 'establishment' || t === 'point_of_interest')); const best = poi || result[0]; setLocation(best.formatted_address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`); } catch (err) { const msg = err && err.code === 1 ? 'Permission denied. Allow location access in your browser to use "Locate me".' : (err && err.message) || 'Could not get your location.'; setPlacesError(msg); } finally { setLocating(false); } } useEffect(() => { if (loading) return; // wait until input is rendered if (!locationInputRef.current) return; // If the autocomplete is bound to a stale DOM node (e.g. user moved // from the onboarding form to the edit form), tear it down and re-attach. if (autocompleteRef.current) { if (autocompleteNodeRef.current === locationInputRef.current) return; try { window.google?.maps?.event?.clearInstanceListeners?.(autocompleteRef.current); } catch {} autocompleteRef.current = null; autocompleteNodeRef.current = null; } let cancelled = false; setPlacesLoading(true); loadGoogleMaps() .then((google) => { if (cancelled || !locationInputRef.current) return; const ac = new google.maps.places.Autocomplete(locationInputRef.current, { fields: [ 'name', 'formatted_address', 'address_components', 'geometry', 'editorial_summary', 'types', 'website', 'international_phone_number', ], types: ['establishment'], }); autocompleteNodeRef.current = locationInputRef.current; autocompleteRef.current = ac; ac.addListener('place_changed', () => { const place = ac.getPlace(); if (!place) return; // Always update location to the formatted address (or name fallback). const addr = place.formatted_address || place.name || ''; if (addr) setLocation(addr); // Auto-fill shop name only if empty. if (place.name && !nameRef.current.trim()) setName(place.name); // Auto-fill description from editorial summary if empty. const summary = place.editorial_summary && place.editorial_summary.overview; if (summary && !descriptionRef.current.trim()) setDescription(summary); // Auto-fill phone if empty. if (place.international_phone_number && !phoneRef.current.trim()) { setPhone(place.international_phone_number); } // Auto-fill shop category from Google Places types if empty. if (!categoryRef.current && Array.isArray(place.types)) { const guess = placeTypesToShopCategory(place.types); if (guess) setCategory(guess); } }); }) .catch((err) => { if (!cancelled) setPlacesError(err.message || 'Could not load location autocomplete.'); }) .finally(() => { if (!cancelled) setPlacesLoading(false); }); return () => { cancelled = true; }; }, [loading, shop]); const reloadGarments = useCallback(async (shopId) => { try { const g = await apiCall(`/shops/${shopId}/garments`); setGarments(sortByCreatedDesc(g || [])); } catch {} }, [apiCall]); useEffect(() => { let cancelled = false; (async () => { try { const s = await apiCall('/shops/me'); if (cancelled) return; setShop(s); if (s) { setName(s.name || ''); setDescription(s.description || ''); setLocation(s.location || ''); setCategory(s.category || ''); setPhone(s.phone || ''); setIsPublic(s.is_public !== false); setTailorPieces(Array.isArray(s.tailor_pieces) ? s.tailor_pieces : []); setShopGender(s.shop_gender || ''); const g = await apiCall(`/shops/${s.id}/garments`); if (!cancelled) setGarments(sortByCreatedDesc(g || [])); } } catch (e) { if (!cancelled) setError(e.message); } finally { if (!cancelled) setLoading(false); } })(); apiCall('/prompts/categories') .then((r) => { if (!cancelled) setCategories(r?.categories || []); }) .catch(() => {}); return () => { cancelled = true; }; }, [apiCall]); async function saveShop(e) { e.preventDefault(); setError(''); if (!name.trim()) return setError(t('shop.nameRequired')); if (isPublic && !category) return setError(t('shop.categoryRequired')); if (category === 'tailor' && !shopGender) return setError(t('shop.genderRequired')); setSaving(true); try { const path = shop ? `/shops/${shop.id}` : '/shops'; const method = shop ? 'PUT' : 'POST'; const s = await apiCall(path, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim(), description: description.trim() || null, location: location.trim() || null, category: category || null, phone: phone.trim() || null, is_public: isPublic, tailor_pieces: category === 'tailor' ? tailorPieces : null, shop_gender: category === 'tailor' ? shopGender : null, }), }); setShop(s); if (s) setIsPublic(s.is_public !== false); setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1800); } catch (e) { setError(e.message); } finally { setSaving(false); } } // ----- Bulk upload (mirrors ManageShopPage logic) ----- function addFilesToQueue(files) { const arr = Array.from(files || []).filter((f) => f.type.startsWith('image/')); if (arr.length === 0) return; const items = arr.map((f) => { // Title = the original file name (extension removed), shown as-is. const base = (f.name || '').replace(/\.[^.]+$/, '').trim(); return { key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: f, preview: URL.createObjectURL(f), title: base || 'Untitled', price: '', description: '', status: 'ready', }; }); setQueue((cur) => [...cur, ...items]); } function onBulkPick(e) { addFilesToQueue(e.target.files); if (bulkInputRef.current) bulkInputRef.current.value = ''; } function onDropFiles(e) { e.preventDefault(); addFilesToQueue(e.dataTransfer?.files); } function updateQueueItem(key, patch) { setQueue((cur) => cur.map((q) => (q.key === key ? { ...q, ...patch } : q))); } function removeQueueItem(key) { setQueue((cur) => cur.filter((q) => q.key !== key)); } function clearQueue() { setQueue([]); } async function uploadAll() { if (!shop) return setError(t('shop.saveFirst')); if (queue.length === 0) return; setError(''); setUploading(true); for (const item of queue) { if (item.status === 'done') continue; if (!item.title.trim()) { updateQueueItem(item.key, { status: 'error', error: 'Title is required' }); continue; } updateQueueItem(item.key, { status: 'uploading', error: undefined }); try { const fd = new FormData(); fd.append('title', item.title.trim()); if (item.price) fd.append('price', item.price); if (item.description) fd.append('description', item.description); fd.append('photo', item.file); await apiCall(`/shops/${shop.id}/garments`, { method: 'POST', body: fd }); updateQueueItem(item.key, { status: 'done' }); } catch (err) { updateQueueItem(item.key, { status: 'error', error: err.message }); } } setUploading(false); setQueue((cur) => cur.filter((q) => q.status !== 'done')); if (shop) reloadGarments(shop.id); } return (
{loading ? (

{t('boot.loading')}

) : ( <>
{t('shop.eyebrowStudio')}

{t('shop.shopDetailsTitle')} {t('shop.shopDetailsTitleGrad')}

{shop && ( )}
{!shop ? (
📷 {t('shop.bannerLockedTitle')} {t('shop.bannerLockedHint')}
) : (
{ if (bannerBusy) return; if (hasNativePicker()) { pickBannerNative(); return; } if (bannerInputRef.current) bannerInputRef.current.click(); }} role="button" tabIndex={0} > {!shop.has_banner && (
📷 {t('shop.uploadBanner')} {t('shop.bannerSubtitle')}
)} {shop.has_banner && (
)}
)}
setLocation(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault(); }} placeholder={placesLoading ? t('shop.placesLoading') : t('shop.searchMapsFull')} autoComplete="off" />
{placesError && ( {t('shop.placeUnavailable')} {placesError} )}
setName(e.target.value)} placeholder={t('shop.namePlaceholder')} />
setPhone(e.target.value)} placeholder="+91 98765 43210" autoComplete="tel" inputMode="tel" pattern="[+0-9\s\-()]*" />
{category === 'tailor' && (
{[{ value: 'men', label: t('shop.genderMen') }, { value: 'women', label: t('shop.genderWomen') }].map((g) => ( ))}

{t('shop.genderRequired')}

)}