import { Component, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import type { WidgetTranslations } from '../i18n'; import type { ProductVariantInput, TargetingRule, WidgetSizeConfig } from '../types'; import { MAX_HEIGHT_CM, MAX_WEIGHT_KG, MIN_HEIGHT_CM, MIN_WEIGHT_KG, SizeAPI, SizeEstimateError, dataUrlToBlob, type QcVerdict, type SizeEstimate, } from '../sizeApi'; import { getBodyInputs, saveBodyInputs } from '../storage'; import { newRunId, trackProductEvent } from '../analytics'; import { fileToBase64, resizeAvatarImage } from '../imageUtils'; interface Props { t: WidgetTranslations; api: SizeAPI; productId: string; productCategories: string[]; productVariants: ProductVariantInput[]; sizeConfig: WidgetSizeConfig; photo: string; onAddToCart: (variantId: string, size: string) => Promise; runId: string; } type Phase = 'offer' | 'waiting-probe' | 'inputs' | 'refining-weight' | 'estimating' | 'result' | 'failed' | 'withdrawn'; function featureEnabled(rule: TargetingRule | undefined, categories: string[], productId: string): boolean { if (!rule || (rule.mode !== 'include' && rule.mode !== 'exclude')) return true; const matched = categories.some((category) => (rule.categories || []).includes(category)) || (rule.productIds || []).map(String).includes(String(productId)); return rule.mode === 'include' ? matched : !matched; } function guideForProduct(size: WidgetSizeConfig, categories: string[], variants: ProductVariantInput[]) { if (!Array.isArray(size.guides)) return size.hasSizeGuide ? { labels: variants.map((v) => v.size_label) } : null; const assigned = size.guides.find((guide) => (guide.categories?.categories || []).some((category) => categories.includes(category)) ); const fallback = size.guides.find((guide) => (guide.categories?.categories || []).length === 0); const guide = assigned || fallback || null; if (!guide) return null; return guide.labels.some((label) => variants.some((variant) => variant.size_label === label)) ? guide : null; } function qcMessage(t: WidgetTranslations, verdict: QcVerdict): string { if (verdict.reason === 'person_too_small') return t.size.qcPersonTooSmall; if (verdict.reason === 'no_person') return t.size.qcNoPerson; return t.size.qcNotFullBody; } function parseNumber(raw: string): number | null { if (!raw.trim()) return null; const value = Number(raw.trim().replace(',', '.')); return Number.isFinite(value) ? value : null; } function alternativeDirection(reason: string | undefined): 'roomier' | 'closer' | null { const text = (reason || '').toLowerCase(); if (/roomier|looser|loose/.test(text)) return 'roomier'; if (/closer|tighter|tight/.test(text)) return 'closer'; return null; } function isNoMatchAnswer(estimate: SizeEstimate): boolean { return estimate.confidence_band === 'low' && (estimate.improvement_options?.length || 0) === 0 && (estimate.alternatives?.length || 0) === 0; } function SizeFlowInner({ t, api, productId, productCategories, productVariants, sizeConfig, photo, onAddToCart, runId }: Props) { const photoBlob = useMemo(() => dataUrlToBlob(photo), [photo]); const guide = useMemo( () => guideForProduct(sizeConfig, productCategories, productVariants), [sizeConfig, productCategories, productVariants] ); const gateOpen = !!photoBlob && !!guide && featureEnabled(sizeConfig.targeting, productCategories, productId); const remembered = useMemo(getBodyInputs, []); const [qc, setQc] = useState(null); const [phase, setPhase] = useState('offer'); const [height, setHeight] = useState(remembered.heightCm == null ? '' : String(remembered.heightCm)); const [weight, setWeight] = useState(remembered.weightKg == null ? '' : String(remembered.weightKg)); const [fitPreference, setFitPreference] = useState<'tight' | 'regular' | 'loose'>(sizeConfig.defaultFitPreference || 'regular'); const [estimate, setEstimate] = useState(null); const [selectedVariant, setSelectedVariant] = useState(null); const [error, setError] = useState(''); const [cartError, setCartError] = useState(false); const [fileError, setFileError] = useState(''); const controllerRef = useRef(new AbortController()); const fileInputRef = useRef(null); const requestIdRef = useRef(''); const startedAtRef = useRef(0); const offerTrackedRef = useRef(false); const qcIndexRef = useRef(0); const submitIndexRef = useRef(0); const refineIndexRef = useRef(0); const givenRef = useRef<{ heightCm: number | null; weightKg: number | null }>({ heightCm: null, weightKg: null }); useEffect(() => () => controllerRef.current.abort(), []); useEffect(() => { if (!gateOpen || !photoBlob) return; void api.probePhotoQuality(photoBlob, controllerRef.current.signal).then((verdict) => { qcIndexRef.current += 1; const knownReasons = ['not_full_body', 'person_too_small', 'no_person']; trackProductEvent('size_photo_qc_completed', { ok: verdict.ok, ...(verdict.reason ? { reason: knownReasons.includes(verdict.reason) ? verdict.reason : 'unknown' } : {}), productId, runId, qcIndex: qcIndexRef.current }); setQc(verdict); setPhase((current) => current === 'waiting-probe' ? (verdict.ok ? 'inputs' : 'offer') : current); }); }, [api, gateOpen, photoBlob, productId, runId]); useEffect(() => { if (!gateOpen || offerTrackedRef.current) return; offerTrackedRef.current = true; trackProductEvent('size_offer_shown', { productId, guideResolution: sizeConfig.guides?.some((item) => (item.categories?.categories || []).length === 0) ? 'shop_default' : 'collection', runId, }); }, [gateOpen, productId, runId, sizeConfig.guides]); if (!gateOpen || phase === 'withdrawn') return null; function start() { trackProductEvent('size_offer_accepted', { productId, runId }); setPhase(qc === null ? 'waiting-probe' : qc.ok ? 'inputs' : 'offer'); } async function submit(event: React.FormEvent) { event.preventDefault(); const heightCm = phase === 'refining-weight' ? givenRef.current.heightCm : parseNumber(height); const weightKg = parseNumber(weight); if (heightCm === null || heightCm < MIN_HEIGHT_CM || heightCm > MAX_HEIGHT_CM) { setError(t.size.invalidHeight); return; } if ((phase === 'refining-weight' && weightKg === null) || (weightKg !== null && (weightKg < MIN_WEIGHT_KG || weightKg > MAX_WEIGHT_KG))) { setError(t.size.invalidWeight); return; } if (!photoBlob) return; setError(''); submitIndexRef.current += 1; trackProductEvent('size_inputs_submitted', { hasWeight: weightKg !== null, productId, runId, submitIndex: submitIndexRef.current }); saveBodyInputs(heightCm, weightKg); givenRef.current = { heightCm, weightKg }; setPhase('estimating'); requestIdRef.current = newRunId(); startedAtRef.current = Date.now(); trackProductEvent('size_estimation_requested', { requestId: requestIdRef.current, hasWeight: weightKg !== null, productId, runId }); try { const result = await api.estimate({ productId, productCategories, productVariants, heightCm, weightKg, fitPreference, photo: photoBlob, }, controllerRef.current.signal); setEstimate(result); setSelectedVariant(result.recommended_variant_id || null); trackProductEvent('size_estimation_completed', { requestId: requestIdRef.current, recommendedSize: result.recommended_size, confidenceBand: result.confidence_band, cacheHit: result.cache_hit === true, betweenSizes: result.between_sizes === true, durationMs: Date.now() - startedAtRef.current, productId, runId, }); setPhase('result'); } catch (caught) { const rawReason = caught instanceof SizeEstimateError ? caught.code : 'unexpected'; const declaredReasons = ['photo_unusable', 'size_guide_not_usable', 'size_guide_not_applicable', 'engine_unavailable', 'timeout', 'invalid_input', 'unreachable', 'unreadable_response', 'unexpected']; const reason = declaredReasons.includes(rawReason) ? rawReason : rawReason === 'invalid_height' || rawReason === 'invalid_weight' ? 'invalid_input' : 'unexpected'; trackProductEvent('size_estimation_failed', { requestId: requestIdRef.current, reason, retryable: reason === 'engine_unavailable' || reason === 'timeout' || reason === 'unreachable', durationMs: Date.now() - startedAtRef.current, productId, runId, }); if (caught instanceof SizeEstimateError && (caught.code === 'size_guide_not_usable' || caught.code === 'size_guide_not_applicable')) { setPhase('withdrawn'); return; } if (caught instanceof SizeEstimateError && caught.code === 'photo_unusable') { setQc({ ok: false, reason: caught.reason }); setPhase('offer'); return; } setPhase('failed'); } } function refineWithWeight() { refineIndexRef.current += 1; trackProductEvent('size_refinement_requested', { kind: 'weight', productId, runId, refineIndex: refineIndexRef.current }); setWeight(''); setError(''); setPhase('refining-weight'); } async function refineWithPhoto(file: File) { if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { setFileError(t.size.invalidPhoto); return; } setFileError(''); refineIndexRef.current += 1; trackProductEvent('size_refinement_requested', { kind: 'photo', productId, runId, refineIndex: refineIndexRef.current }); setPhase('estimating'); try { const resized = await resizeAvatarImage(await fileToBase64(file)); const replacement = dataUrlToBlob(resized); const { heightCm, weightKg } = givenRef.current; if (!replacement || heightCm === null) throw new Error('invalid replacement photo'); requestIdRef.current = newRunId(); startedAtRef.current = Date.now(); trackProductEvent('size_estimation_requested', { requestId: requestIdRef.current, hasWeight: weightKg !== null, productId, runId }); const result = await api.estimate({ productId, productCategories, productVariants, heightCm, weightKg, fitPreference, photo: replacement }, controllerRef.current.signal); setEstimate(result); setSelectedVariant(result.recommended_variant_id || null); trackProductEvent('size_estimation_completed', { requestId: requestIdRef.current, recommendedSize: result.recommended_size, confidenceBand: result.confidence_band, cacheHit: result.cache_hit === true, betweenSizes: result.between_sizes === true, durationMs: Date.now() - startedAtRef.current, productId, runId, }); setPhase('result'); } catch { trackProductEvent('size_estimation_failed', { requestId: requestIdRef.current, reason: 'unexpected', retryable: false, durationMs: startedAtRef.current ? Date.now() - startedAtRef.current : 0, productId, runId, }); setPhase('failed'); } } const alternative = estimate?.between_sizes ? estimate.alternatives?.[0] : undefined; const choices = estimate ? [ { size: estimate.recommended_size, variantId: estimate.recommended_variant_id }, ...(alternative ? [{ size: alternative.size, variantId: alternative.variant_id }] : []), ].filter((choice) => !!choice.variantId) : []; const selected = choices.find((choice) => choice.variantId === selectedVariant); const direction = alternativeDirection(alternative?.reason); const bandCopy = estimate && isNoMatchAnswer(estimate) ? t.size.bandNoMatch : estimate?.confidence_band === 'very_confident' ? t.size.bandVeryConfident : estimate?.confidence_band === 'good' ? t.size.bandGood : t.size.bandLow; return (
{phase === 'offer' && (

{t.size.offerTitle}

{qc && !qc.ok ?

{qcMessage(t, qc)}

: ( )}
)} {phase === 'waiting-probe' &&

{t.size.checkingPhoto}

} {(phase === 'inputs' || phase === 'refining-weight') && (
void submit(event)} noValidate> {phase !== 'refining-weight' && } {phase !== 'refining-weight' && sizeConfig.fitPreferenceEnabled && } {error &&

{error}

}
)} {phase === 'estimating' &&

{t.size.estimating}

} {phase === 'result' && estimate && (

{t.size.resultTitle}

{estimate.recommended_size}

{bandCopy}

{estimate.between_sizes &&

{t.size.betweenSizes}

} {alternative &&

{direction === 'roomier' ? t.size.alsoConsiderRoomier(alternative.size) : direction === 'closer' ? t.size.alsoConsiderCloser(alternative.size) : t.size.alsoConsider(alternative.size)}

} {choices.length > 1 &&
{choices.map((choice) => ( ))}
} {selected &&

{t.size.cartUsesSize(selected.size)}

} {selected && } {cartError &&

{t.size.addToCartError}

} {estimate.improvement_options?.includes('add_weight') && givenRef.current.weightKg === null && } {estimate.improvement_options?.includes('retake_photo') && <> { const file = event.target.files?.[0]; if (fileInputRef.current) fileInputRef.current.value = ''; if (file) void refineWithPhoto(file); }} /> } {fileError &&

{fileError}

}
)} {phase === 'failed' &&

{t.size.failed}

}
); } class SizeBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } render() { return this.state.failed ? null : this.props.children; } } export function SizeFlow(props: Props) { return ; }