webs-sdk 0.19.64 → 0.19.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/AGENTS.md +44 -0
  2. package/CLAUDE.md +80 -0
  3. package/README.md +513 -490
  4. package/dist/components/ARFilters/ARFilters.js +54 -54
  5. package/dist/components/ArtGenerator.js +141 -141
  6. package/dist/components/AvatarAI.js +14 -14
  7. package/dist/components/CookieConsent/constants.js +31 -31
  8. package/dist/components/CreativeFaceSwap.js +23 -23
  9. package/dist/components/GifsIa.js +76 -76
  10. package/dist/components/MemeGenerator.js +12 -12
  11. package/dist/components/PasswordReset/constants.js +33 -33
  12. package/dist/components/RecoveryPassword/constants.js +27 -27
  13. package/dist/components/SpinningWheel/SpinningWheel.js +7 -7
  14. package/dist/components/SpinningWheel/index.d.ts +1 -1
  15. package/dist/components/SpinningWheel/index.d.ts.map +1 -1
  16. package/dist/components/SpinningWheel/index.js +2 -1
  17. package/dist/components/SpinningWheel/index.js.map +1 -1
  18. package/dist/components/ui/ConfiguredMediaPlayer.d.ts +49 -0
  19. package/dist/components/ui/ConfiguredMediaPlayer.d.ts.map +1 -0
  20. package/dist/components/ui/ConfiguredMediaPlayer.js +122 -0
  21. package/dist/components/ui/ConfiguredMediaPlayer.js.map +1 -0
  22. package/dist/components/ui/badge.d.ts +1 -0
  23. package/dist/components/ui/badge.d.ts.map +1 -1
  24. package/dist/components/ui/badge.js +1 -1
  25. package/dist/components/ui/badge.js.map +1 -1
  26. package/dist/components/ui/compose-refs.d.ts +6 -0
  27. package/dist/components/ui/compose-refs.d.ts.map +1 -0
  28. package/dist/components/ui/compose-refs.js +57 -0
  29. package/dist/components/ui/compose-refs.js.map +1 -0
  30. package/dist/components/ui/dropdown-menu.d.ts +3 -1
  31. package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
  32. package/dist/components/ui/dropdown-menu.js +2 -2
  33. package/dist/components/ui/dropdown-menu.js.map +1 -1
  34. package/dist/components/ui/index.d.ts +2 -0
  35. package/dist/components/ui/index.d.ts.map +1 -1
  36. package/dist/components/ui/index.js +2 -0
  37. package/dist/components/ui/index.js.map +1 -1
  38. package/dist/components/ui/media-player.d.ts +127 -0
  39. package/dist/components/ui/media-player.d.ts.map +1 -0
  40. package/dist/components/ui/media-player.js +1633 -0
  41. package/dist/components/ui/media-player.js.map +1 -0
  42. package/dist/components/ui/tooltip.d.ts +3 -1
  43. package/dist/components/ui/tooltip.d.ts.map +1 -1
  44. package/dist/components/ui/tooltip.js +2 -2
  45. package/dist/components/ui/tooltip.js.map +1 -1
  46. package/dist/components/ui/use-lazy-ref.d.ts +4 -0
  47. package/dist/components/ui/use-lazy-ref.d.ts.map +1 -0
  48. package/dist/components/ui/use-lazy-ref.js +45 -0
  49. package/dist/components/ui/use-lazy-ref.js.map +1 -0
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +4 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/network.d.ts +12 -0
  55. package/dist/network.d.ts.map +1 -0
  56. package/dist/network.js +15 -0
  57. package/dist/network.js.map +1 -0
  58. package/dist/server.d.ts +54 -0
  59. package/dist/server.d.ts.map +1 -0
  60. package/dist/server.js +74 -0
  61. package/dist/server.js.map +1 -0
  62. package/dist/types/media-chrome-store-types.d.ts +107 -0
  63. package/dist/types/media-chrome-store-types.d.ts.map +1 -0
  64. package/dist/types/media-chrome-store-types.js +3 -0
  65. package/dist/types/media-chrome-store-types.js.map +1 -0
  66. package/dist/utils/QRCode.d.ts +1 -0
  67. package/dist/utils/QRCode.d.ts.map +1 -0
  68. package/dist/utils/QRCode.js +2 -0
  69. package/dist/utils/QRCode.js.map +1 -0
  70. package/dist/utils/imageValidation.d.ts +6 -0
  71. package/dist/utils/imageValidation.d.ts.map +1 -0
  72. package/dist/utils/imageValidation.js +64 -0
  73. package/dist/utils/imageValidation.js.map +1 -0
  74. package/docs/CONFIGURED_MEDIA_PLAYER.md +40 -0
  75. package/docs/CORP_AUTH_USAGE.md +687 -0
  76. package/docs/EXAMPLES_WALLPAPERSNAME_IMPLEMENTATION.md +1445 -0
  77. package/docs/EXAMPLE_ENDPOINTS_COMUNICATION.md +55 -0
  78. package/docs/EXTERNAL_SDK_GUIDE.md +1340 -0
  79. package/docs/FOR_YOU_360_FACESWAP_FIX.md +400 -0
  80. package/docs/FOR_YOU_IMPLEMENTATION_GUIDE.md +1413 -0
  81. package/docs/FOR_YOU_QUIZ_ARCHITECTURE.md +753 -0
  82. package/docs/FOR_YOU_SDK_WALLPAPERSNAME_BUG.md +106 -0
  83. package/docs/FOR_YOU_WALLPAPERSNAME_ENHANCEMENT_PLAN.md +210 -0
  84. package/docs/LEGAL_USAGE.md +190 -0
  85. package/docs/STREAK_IMPLEMENTATION_GUIDE.md +430 -0
  86. package/package.json +106 -84
  87. package/public/spin2win/assets/border-afristream.svg +26 -26
  88. package/public/spin2win/assets/border-efc.svg +23 -23
  89. package/public/spin2win/assets/border-streetreel.svg +23 -23
  90. package/public/spin2win/assets/border-totalgym.svg +27 -27
  91. package/public/spin2win/assets/border-womantoday.svg +26 -26
  92. package/public/spin2win/assets/indicator-afristream.svg +28 -28
  93. package/public/spin2win/assets/indicator-efc.svg +29 -29
  94. package/public/spin2win/assets/indicator-streetreel.svg +29 -29
  95. package/public/spin2win/assets/indicator-totalgym.svg +29 -29
  96. package/public/spin2win/fonts/switzer/README.md +22 -22
  97. package/scripts/install-agent-skill.cjs +34 -0
  98. package/skills/webs-sdk-package/SKILL.md +21 -0
  99. package/skills/webs-sdk-package/references/components.md +32 -0
  100. package/skills/webs-sdk-package/references/integration.md +32 -0
  101. package/skills/webs-sdk-package/references/ui.md +197 -0
@@ -0,0 +1,753 @@
1
+ # For You Quiz — Arquitectura Completa
2
+
3
+ ## Tabla de Contenidos
4
+
5
+ 1. [Entrada de la página](#1-entrada-de-la-página)
6
+ 2. [ForYouQuizView — Wrapper de la vista](#2-foryouquizview--wrapper-de-la-vista)
7
+ 3. [Helpers del hub: tema y config](#3-helpers-del-hub-tema-y-config)
8
+ 4. [QuizSection — El motor principal](#4-quizsection--el-motor-principal)
9
+ 5. [APIs involucradas](#5-apis-involucradas)
10
+ 6. [Parseo de datos del backend](#6-parseo-de-datos-del-backend)
11
+ 7. [QuizCard — Tarjeta del listado](#7-quizcard--tarjeta-del-listado)
12
+ 8. [QuizModal — El juego en sí](#8-quizmodal--el-juego-en-sí)
13
+ 9. [Cálculo de puntuación](#9-cálculo-de-puntuación)
14
+ 10. [Estructura de datos del backend](#10-estructura-de-datos-del-backend)
15
+ 11. [Diagrama de flujo completo](#11-diagrama-de-flujo-completo)
16
+ 12. [Tipos TypeScript internos](#12-tipos-typescript-internos)
17
+
18
+ ---
19
+
20
+ ## 1. Entrada de la página
21
+
22
+ **Archivo:** `src/components/layout/SPALayout/helpers/renderCurrentView.tsx`
23
+
24
+ La aplicación usa una SPA interna (no pages de Next.js). Hay dos mapas que conectan la URL con el componente:
25
+
26
+ ```ts
27
+ // Mapa URL → clave de vista
28
+ urlToViewMap: {
29
+ "/for-you-quiz": "for-you-quiz"
30
+ }
31
+
32
+ // Registro de vistas: clave → componente
33
+ viewRegistry: {
34
+ "for-you-quiz": () => <ForYouQuizView />
35
+ }
36
+ ```
37
+
38
+ Cuando el `SPALayout` detecta que la ruta activa es `/for-you-quiz`, renderiza `<ForYouQuizView />` sin parámetros adicionales.
39
+
40
+ ---
41
+
42
+ ## 2. ForYouQuizView — Wrapper de la vista
43
+
44
+ **Archivo:** `src/components/pages/ForYouQuizView.tsx`
45
+
46
+ Es el componente de entrada para la vista. Su responsabilidad es:
47
+
48
+ 1. **Proteger el acceso** con `useContentAccess().checkAndRedirect()` (en `useEffect`).
49
+ 2. **Resolver el tema visual** via `useForYouTheme()`.
50
+ 3. **Resolver la config de contenidos** via `useForYouItemConfig('Quiz')`.
51
+ 4. **Extraer tags e IDs** de la config para pasarlos al componente motor.
52
+ 5. **Montar `<QuizSection>`** con toda la configuración unificada.
53
+
54
+ ### Props
55
+
56
+ ```ts
57
+ interface ForYouQuizViewProps {
58
+ config?: any; // Config opcional, puede venir de featured sections
59
+ }
60
+ ```
61
+
62
+ Si se pasa `config` por props se usa esa, sino se usa la obtenida por `useForYouItemConfig('Quiz')`. Esto permite reusar el componente desde otras secciones (ej: hub de For You).
63
+
64
+ ### Extracción de tags e IDs (con `useMemo`)
65
+
66
+ ```ts
67
+ // Une todos los tags de todos los items de contenido de la config
68
+ const allTags = [...new Set(
69
+ itemConfig?.contents?.flatMap((c) => c.tags ?? []) ?? ['quizzes']
70
+ )] as string[];
71
+
72
+ // Une todos los IDs específicos de todos los items de contenido
73
+ const allIds = [...new Set(
74
+ itemConfig?.contents?.flatMap((c) => (c.includeIds ?? []) as number[]) ?? []
75
+ )] as number[];
76
+ ```
77
+
78
+ > Si `itemConfig` es `undefined`, `allTags` tiene como fallback `['quizzes']` y `allIds` queda vacío.
79
+
80
+ ### Config que se pasa a QuizSection
81
+
82
+ ```ts
83
+ {
84
+ apiUrl: '/api/get_contents_by_tags',
85
+ questionsApiUrl: '/api/content-preset',
86
+ website_id, // del websiteStore (product_config.website_id)
87
+ tags: allTags,
88
+ includeIds: allIds,
89
+ locale, // idioma activo de next-intl
90
+ initialCount: 12, // quizzes visibles al inicio
91
+ loadMoreCount: 8, // quizzes adicionales por click en "Load more"
92
+ }
93
+ ```
94
+
95
+ ---
96
+
97
+ ## 3. Helpers del hub: tema y config
98
+
99
+ **Archivo:** `src/components/pages/ForYouHubView.tsx`
100
+
101
+ Estos helpers son exportados y usados por todos los componentes de la sección For You.
102
+
103
+ ### `useForYouTheme()`
104
+
105
+ Lee **variables CSS en tiempo de ejecución** del tema activo del portal. Devuelve un objeto con todos los colores:
106
+
107
+ | Propiedad | Variable CSS | Fallback |
108
+ |---|---|---|
109
+ | `primaryColor` | `--button-primary-bg` | `#FF2273` |
110
+ | `primaryHoverColor` | `--button-primary-hover-bg` | `#E81F69` |
111
+ | `textColor` | `--color-text-secondary` | `#0F1F35` |
112
+ | `backgroundColor` | `--neutral-100` | `#F5F5F5` |
113
+ | `cardBackground` | `--color-surface-base` | `#FFFFFF` |
114
+ | `buttonColor` | `--button-primary-bg` | `#FF2273` |
115
+ | `buttonTextColor` | `--neutral-0` | `#FFFFFF` |
116
+ | `neutralMid` | `--neutral-500` | `#8C8C8C` |
117
+ | `cardBorderColor` | `--color-border-primary` | `rgba(15,31,53,0.1)` |
118
+ | `titleAccentColor` | `--color-brand-accent-500` | `#FF2273` |
119
+ | `titleDarkColor` | `--color-brand-secondary-500` | `#210F5C` |
120
+
121
+ En SSR (window === undefined) devuelve los fallbacks directamente.
122
+
123
+ ### `useForYouItemConfig(itemKey: string)`
124
+
125
+ Busca en `product_config.sections` la sección con `key: 'foryou'` (o `'for-you'`) y dentro de sus `items` el que tenga `key === itemKey`.
126
+
127
+ ```ts
128
+ // Ejemplo de lo que lee del backend:
129
+ product_config.sections = [
130
+ {
131
+ key: 'foryou',
132
+ items: [
133
+ {
134
+ key: 'Quiz',
135
+ contents: [
136
+ { tags: ['quizzes', 'sports-quiz'], includeIds: [832, 900] }
137
+ ]
138
+ }
139
+ ]
140
+ }
141
+ ]
142
+ ```
143
+
144
+ ---
145
+
146
+ ## 4. QuizSection — El motor principal
147
+
148
+ **Archivo:** `src/components/quiz/QuizSection.tsx`
149
+
150
+ Componente que gestiona todo el ciclo de vida del quiz: carga de lista, navegación, modal de juego y resultados.
151
+
152
+ ### Estado interno
153
+
154
+ ```ts
155
+ const [quizItems, setQuizItems] = useState<QuizItem[]>([]);
156
+ const [loading, setLoading] = useState(true);
157
+ const [visibleCount, setVisibleCount] = useState(initialCount);
158
+ const [activeQuiz, setActiveQuiz] = useState<QuizItem | null>(null);
159
+ const [activeQuestions, setActiveQuestions] = useState<QuizQuestion[]>([]);
160
+ const [activeResults, setActiveResults] = useState<QuizResult[]>([]);
161
+ const [loadingQuestions, setLoadingQuestions] = useState(false);
162
+ ```
163
+
164
+ ### Carga del listado (`fetchList`)
165
+
166
+ Disparado al montar y cuando cambian `apiUrl`, `locale`, `tags` o `includeIds`.
167
+
168
+ Hace hasta 2 peticiones en paralelo con `Promise.all`:
169
+
170
+ ```
171
+ Si tags.length > 0:
172
+ POST /api/get_contents_by_tags
173
+ { language, tags, website_id }
174
+
175
+ Si includeIds.length > 0:
176
+ POST /api/get_contents_by_tags
177
+ { language, includeIds, preset: 'getGlobalContentsByIDs', website_id }
178
+ ```
179
+
180
+ Los resultados de ambas se normalizan, se deduplican por `id` y se convierten en `QuizItem[]`.
181
+
182
+ ### Carga de preguntas (`handleCardClick`)
183
+
184
+ Disparado cuando el usuario hace click en una card.
185
+
186
+ ```
187
+ POST /api/content-preset
188
+ {
189
+ preset: "getQuiz",
190
+ params: {
191
+ quiz_id: quiz.contentsId,
192
+ language: locale,
193
+ website_id
194
+ }
195
+ }
196
+ ```
197
+
198
+ Luego llama a `extractQuestions(rawItem, locale)` y `extractResults(rawItem, locale)`.
199
+
200
+ ### Paginación del listado
201
+
202
+ ```ts
203
+ const visible = quizItems.slice(0, visibleCount);
204
+ const hasMore = visibleCount < quizItems.length;
205
+
206
+ // Al hacer click en "Load more":
207
+ setVisibleCount((c) => c + loadMoreCount);
208
+ ```
209
+
210
+ ---
211
+
212
+ ## 5. APIs involucradas
213
+
214
+ ### `POST /api/get_contents_by_tags`
215
+
216
+ **Archivo:** `src/app/api/get_contents_by_tags/route.ts`
217
+
218
+ Punto de entrada para obtener la **lista de quizzes disponibles**.
219
+
220
+ | Param en body | Descripción |
221
+ |---|---|
222
+ | `language` | Idioma activo (ej: `'es'`) |
223
+ | `tags` | Array de strings (ej: `['quizzes']`) |
224
+ | `website_id` | ID del portal |
225
+ | `preset` | Override del preset SDK (por defecto `'getGlobalContentsByTags'`) |
226
+ | `includeIds` | Array de IDs numéricos → se mapean a `id_list` como enteros |
227
+
228
+ Internamente llama a:
229
+ ```ts
230
+ WebsSDK.ContentManager.getContentByPreset(preset, params)
231
+ ```
232
+
233
+ ### `POST /api/content-preset`
234
+
235
+ **Archivo:** `src/app/api/content-preset/route.ts`
236
+
237
+ Punto de entrada para obtener las **preguntas de un quiz específico**.
238
+
239
+ | Param en body | Descripción |
240
+ |---|---|
241
+ | `preset` | Nombre del preset SDK (ej: `'getQuiz'`) |
242
+ | `params.quiz_id` | ID del quiz (el `contentsId` del QuizItem) |
243
+ | `params.language` | Idioma activo |
244
+ | `params.website_id` | ID del portal |
245
+
246
+ Internamente llama a:
247
+ ```ts
248
+ WebsSDK.ContentManager.getContentByPreset('getQuiz', params)
249
+ ```
250
+
251
+ La respuesta del endpoint tiene este formato:
252
+ ```json
253
+ {
254
+ "success": true,
255
+ "preset": "getQuiz",
256
+ "data": { ... },
257
+ "params": { ... }
258
+ }
259
+ ```
260
+
261
+ ---
262
+
263
+ ## 6. Parseo de datos del backend
264
+
265
+ ### `normalizeResponse(json)`
266
+
267
+ Maneja múltiples formatos de envelope del backend para devolver siempre `RawContent[]`:
268
+
269
+ ```
270
+ json[] → devuelve directo
271
+ json.data.data.data.quiz_configuration → single object con quiz
272
+ json.data.data.data[] → array
273
+ json.data.data[] → array
274
+ json.data.contents[] → array
275
+ json.data[] → array
276
+ json.data (single object) → array de 1
277
+ json.quiz_configuration → single object
278
+ ```
279
+
280
+ ### `toQuizItem(item)` — transforma RawContent a QuizItem
281
+
282
+ Extrae:
283
+ - `id` ← `item.id`
284
+ - `title` ← `item.title || item.name`
285
+ - `description` ← `item.description`
286
+ - `thumbnail` ← `item.thumbnail || item.image || item.files[0].url || item.images.img1`
287
+ - `contentsId` ← `String(item.contents)` — **el ID del quiz con preguntas**
288
+ - `personality` ← `meta.personality === true` (booleano o string `'true'`)
289
+
290
+ > Si `title` o `contentsId` están vacíos, devuelve `null` (el item se descarta).
291
+
292
+ ### `extractQuestions(item, locale)` — extrae preguntas del quiz
293
+
294
+ Busca `quiz_configuration` en `item.metadata` o directamente en `item`.
295
+
296
+ **Resolución del locale** (tolerante a variaciones):
297
+ ```
298
+ 'ES' → locale.toUpperCase()
299
+ 'es' → locale (literal)
300
+ 'FR' → locale.substring(0,2).toUpperCase() // de 'fr-FR'
301
+ ```
302
+
303
+ Para cada `pregunta` en `langs[LOCALE].preguntas`:
304
+ - `question` ← `pregunta.titulo`
305
+ - `options[]` ← `pregunta.respuestas.map(r => ({ text: r.titulo, points: r.puntos ?? 0 }))`
306
+ - `maxPoints` ← `Math.max(...options.map(o => o.points))`
307
+ - `id` ← `${item.id}_q${index}`
308
+
309
+ Si no hay `quiz_configuration`, usa el **formato legacy** (`toQuestion()`):
310
+ - `meta.respuestas[]` con `{ titulo, puntos }` → puntos-based
311
+ - `meta.options[]` o `meta.option_a/b/c/d` → legacy correcto/incorrecto
312
+ - `meta.correct_option || meta.correct || meta.answer` → índice correcto
313
+
314
+ ### `extractResults(item, locale)` — extrae mensajes de resultado
315
+
316
+ Busca en `langs[LOCALE].resultados[]`:
317
+ - `title` / `description` ← `resultado.titulo`
318
+ - `minPoints` ← `resultado.puntos_ini`
319
+ - `maxPoints` ← `resultado.puntos_fin`
320
+
321
+ ---
322
+
323
+ ## 7. QuizCard — Tarjeta del listado
324
+
325
+ Componente interno de `QuizSection`. Muestra:
326
+
327
+ - **Thumbnail** (ratio 16:9) — si no hay imagen muestra un círculo numerado con `primaryColor`
328
+ - **Título** (máx. 2 líneas con `WebkitLineClamp`)
329
+ - **Descripción** (máx. 2 líneas, si existe)
330
+ - **Botón CTA** — texto del texto `startQuiz`
331
+
332
+ Estilos en hover:
333
+ - Border cambia a `primaryColor`
334
+ - `translateY(-2px)` (levitación)
335
+ - Sombra aumenta: `0 8px 24px rgba(0,0,0,0.12)`
336
+
337
+ Al hacer click delega a `handleCardClick(quiz)` que está en `QuizSection`.
338
+
339
+ ---
340
+
341
+ ## 8. QuizModal — El juego en sí
342
+
343
+ Componente interno de `QuizSection`. Se monta en un **portal fixed** (overlay con `z-index: 9999`).
344
+
345
+ ### Estado del modal
346
+
347
+ ```ts
348
+ const [currentIndex, setCurrentIndex] = useState(startIndex);
349
+ const [answers, setAnswers] = useState<Map<number, number>>(new Map());
350
+ // Map<questionIndex, selectedOptionIndex>
351
+ const [finished, setFinished] = useState(false);
352
+ ```
353
+
354
+ ### Comportamiento durante el juego
355
+
356
+ **Barra de progreso:**
357
+ ```
358
+ width = ((currentIndex + 1) / total) * 100%
359
+ Label = formatLabel(questionLabel, { current: currentIndex+1, total })
360
+ ```
361
+
362
+ **Selección de opción:**
363
+ - Guarda `Map.set(currentIndex, optionIndex)`.
364
+ - El usuario puede cambiar la respuesta antes de avanzar.
365
+ - Puede volver a preguntas anteriores (el botón Anterior re-activa la respuesta previa).
366
+
367
+ **Coloreado de opciones:**
368
+
369
+ | Modo | Estado | Comportamiento |
370
+ |---|---|---|
371
+ | Puntos (sin `correctIndex`) | Opción seleccionada | Se pinta con `primaryColor` |
372
+ | Puntos (sin `correctIndex`) | Sin responder | Color neutro de card |
373
+ | Legacy (`correctIndex` definido) | Después de responder | Correcta = verde, Incorrecta = rojo |
374
+
375
+ **Feedback (solo legacy):**
376
+ ```
377
+ Correcto → texts.correctAnswer (verde #2E7D32)
378
+ Incorrecto → texts.wrongAnswer (rojo #C62828) + explanation opcional
379
+ ```
380
+
381
+ **Navegación:**
382
+ - Botón **Anterior**: solo visible si `currentIndex > 0`
383
+ - Botón **Siguiente/Enviar**:
384
+ - Deshabilitado (`cursor: not-allowed`, opacidad 50%) si no hay respuesta
385
+ - Texto = `nextButton` si no es la última pregunta
386
+ - Texto = `submitButton` en la última pregunta
387
+ - Al hacer click en la última pregunta → `setFinished(true)`
388
+
389
+ **Cierre del modal:**
390
+ - Click fuera del modal (overlay)
391
+ - Tecla `Escape`
392
+ - Botón ✕
393
+ - Scroll del body bloqueado (`document.body.style.overflow = 'hidden'`) mientras está abierto
394
+
395
+ ---
396
+
397
+ ## 9. Cálculo de puntuación
398
+
399
+ Cuando `finished === true` el modal entra en **pantalla de resultado**.
400
+
401
+ ### Algoritmo de puntuación
402
+
403
+ ```ts
404
+ let score = 0;
405
+
406
+ answers.forEach((optionIdx, questionIdx) => {
407
+ const question = questions[questionIdx];
408
+
409
+ if (question.correctIndex === undefined) {
410
+ // Quiz por puntos: suma los puntos de la opción elegida
411
+ score += question.options[optionIdx].points;
412
+ } else {
413
+ // Quiz legacy: suma 1 por cada respuesta correcta
414
+ if (optionIdx === question.correctIndex) score += 1;
415
+ }
416
+ });
417
+ ```
418
+
419
+ ### Cálculo del total máximo (para quizzes por puntos)
420
+
421
+ ```ts
422
+ const dynamicMax = questions.reduce((sum, q) => sum + (q.maxPoints ?? 0), 0);
423
+
424
+ // Prioridad:
425
+ // 1. dynamicMax si > 0 (suma de maxPoints por pregunta)
426
+ // 2. results[last].maxPoints (tope del último resultado)
427
+ // 3. score actual (fallback)
428
+ ```
429
+
430
+ ### Matching del resultado
431
+
432
+ ```ts
433
+ const matchedResult = results.find(
434
+ r => score >= r.minPoints && score <= r.maxPoints
435
+ );
436
+ resultMessage = matchedResult?.description;
437
+ ```
438
+
439
+ ### Pantalla de resultado según tipo
440
+
441
+ **Quiz por puntos, modo normal** (`personality !== true`):
442
+ ```
443
+ 🏆 (icono)
444
+ "{score}/{totalMax} puntos" ← formato de scoreLabel
445
+ "Eres un crack!" ← resultMessage (si encontró matching)
446
+ ```
447
+
448
+ **Quiz por puntos, modo `personality: true`**:
449
+ ```
450
+ 🏆 (icono)
451
+ "Eres un experto del fútbol" ← resultMessage en grande (24px, negrita)
452
+ (SIN mostrar el marcador numérico)
453
+ ```
454
+
455
+ **Quiz legacy (correcto/incorrecto)**:
456
+ ```
457
+ 🏆 (icono)
458
+ "3/5" ← score/total de preguntas
459
+ (sin resultMessage)
460
+ ```
461
+
462
+ ### Acciones en la pantalla de resultado
463
+
464
+ - **Retry** → `setCurrentIndex(startIndex)`, `setAnswers(new Map())`, `setFinished(false)`
465
+ - **Close** → `onClose()` → limpia `activeQuiz`, `activeQuestions`, `activeResults` en `QuizSection`
466
+
467
+ ---
468
+
469
+ ## 10. Estructura de datos del backend
470
+
471
+ ### Item del listado de quizzes
472
+
473
+ Respuesta de `getGlobalContentsByTags`:
474
+
475
+ ```json
476
+ {
477
+ "id": 100,
478
+ "title": "¿Cuánto sabes de Fútbol?",
479
+ "description": "Pon a prueba tus conocimientos",
480
+ "thumbnail": "https://cdn.example.com/quiz-thumb.jpg",
481
+ "contents": "832",
482
+ "metadata": "{\"personality\": false}"
483
+ }
484
+ ```
485
+
486
+ > `contents` es el campo clave — contiene el ID del quiz con preguntas.
487
+
488
+ ### Item completo del quiz (respuesta de `getQuiz`)
489
+
490
+ ```json
491
+ {
492
+ "id": 832,
493
+ "title": "Quiz de Fútbol",
494
+ "quiz_configuration": {
495
+ "langs": {
496
+ "ES": {
497
+ "preguntas": [
498
+ {
499
+ "titulo": "¿Quién ganó el Mundial 2022?",
500
+ "respuestas": [
501
+ { "titulo": "Francia", "puntos": 0 },
502
+ { "titulo": "Argentina", "puntos": 10 },
503
+ { "titulo": "Brasil", "puntos": 0 }
504
+ ]
505
+ },
506
+ {
507
+ "titulo": "¿Cuántos balones de oro tiene Messi?",
508
+ "respuestas": [
509
+ { "titulo": "6", "puntos": 0 },
510
+ { "titulo": "7", "puntos": 5 },
511
+ { "titulo": "8", "puntos": 10 }
512
+ ]
513
+ }
514
+ ],
515
+ "resultados": [
516
+ { "titulo": "¡Eres un crack total!", "puntos_ini": 15, "puntos_fin": 20 },
517
+ { "titulo": "Buen nivel futbolero", "puntos_ini": 8, "puntos_fin": 14 },
518
+ { "titulo": "Sigue practicando", "puntos_ini": 0, "puntos_fin": 7 }
519
+ ]
520
+ },
521
+ "EN": {
522
+ "preguntas": [ ... ],
523
+ "resultados": [ ... ]
524
+ }
525
+ }
526
+ }
527
+ }
528
+ ```
529
+
530
+ ### Item quiz formato legacy (correcto/incorrecto)
531
+
532
+ ```json
533
+ {
534
+ "id": 200,
535
+ "title": "¿Sabes de Tenis?",
536
+ "metadata": {
537
+ "question": "¿Cuántos Grand Slams tiene Nadal?",
538
+ "options": ["20", "21", "22", "23"],
539
+ "correct_option": 2,
540
+ "explanation": "Nadal ganó 22 Grand Slams antes de retirarse."
541
+ }
542
+ }
543
+ ```
544
+
545
+ Alternativa con `option_a..d`:
546
+ ```json
547
+ {
548
+ "metadata": {
549
+ "question": "¿Cuál es la capital del tenis?",
550
+ "option_a": "Wimbledon",
551
+ "option_b": "Roland Garros",
552
+ "option_c": "Flushing Meadows",
553
+ "option_d": "Melbourne Park",
554
+ "correct": 0
555
+ }
556
+ }
557
+ ```
558
+
559
+ ---
560
+
561
+ ## 11. Diagrama de flujo completo
562
+
563
+ ```
564
+ URL: /for-you-quiz
565
+ │
566
+ ▼
567
+ SPALayout → renderCurrentView → <ForYouQuizView />
568
+ │
569
+ ▼
570
+ useForYouItemConfig('Quiz')
571
+ └─ product_config.sections[key='foryou'].items[key='Quiz']
572
+ └─ → { contents: [{ tags: ['quizzes'], includeIds: [832] }] }
573
+ │
574
+ ▼
575
+ allTags = ['quizzes']
576
+ allIds = [832]
577
+ │
578
+ ▼
579
+ <QuizSection config theme texts>
580
+ │
581
+ ▼ (mount / locale/tags change)
582
+ fetchList()
583
+ ├─ POST /api/get_contents_by_tags {tags: ['quizzes'], language, website_id}
584
+ │ └─ SDK: getGlobalContentsByTags
585
+ └─ POST /api/get_contents_by_tags {includeIds: [832], preset: 'getGlobalContentsByIDs'}
586
+ └─ SDK: getGlobalContentsByIDs
587
+ │
588
+ ▼
589
+ normalizeResponse() → toQuizItem[] → quizItems (deduplicados)
590
+ │
591
+ ▼
592
+ Grid de <QuizCard> (12 iniciales, +8 por Load More)
593
+ │
594
+ ▼ (click en card)
595
+ handleCardClick(quiz)
596
+ └─ POST /api/content-preset {preset: 'getQuiz', params: {quiz_id: '832', language, website_id}}
597
+ └─ SDK: getContentByPreset('getQuiz', ...)
598
+ │
599
+ ▼
600
+ normalizeResponse() → rawItems
601
+ extractQuestions(rawItems[0], locale) → QuizQuestion[]
602
+ extractResults(rawItems[0], locale) → QuizResult[]
603
+ │
604
+ ▼
605
+ <QuizModal questions results personality>
606
+ │
607
+ ├─ Pregunta actual → opciones clickables
608
+ ├─ Barra de progreso
609
+ ├─ Botón Anterior (si currentIndex > 0)
610
+ └─ Botón Siguiente/Enviar (deshabilitado sin respuesta)
611
+ │
612
+ ▼ (última pregunta respondida → Submit)
613
+ finished = true
614
+ │
615
+ ▼
616
+ score = Σ options[selectedIdx].points (puntos-based)
617
+ ó Σ (optionIdx === correctIndex) (legacy)
618
+ │
619
+ ▼
620
+ matchedResult = results.find(r => score >= r.min && score <= r.max)
621
+ │
622
+ ▼
623
+ Pantalla de resultado:
624
+ - personality=false → "score/totalMax" + resultMessage
625
+ - personality=true → solo resultMessage en grande
626
+ - legacy → "score/totalPreguntas"
627
+ │
628
+ ├─ [Retry] → resetear estado del modal
629
+ └─ [Close] → cerrar modal, limpiar estado
630
+ ```
631
+
632
+ ---
633
+
634
+ ## 12. Tipos TypeScript internos
635
+
636
+ **Archivo:** `src/components/quiz/QuizSection.tsx`
637
+
638
+ ```ts
639
+ // Config del componente (entrada)
640
+ interface QuizConfig {
641
+ apiUrl: string;
642
+ questionsApiUrl?: string;
643
+ tags?: string[];
644
+ includeIds?: number[];
645
+ locale?: string;
646
+ initialCount?: number;
647
+ loadMoreCount?: number;
648
+ website_id?: string;
649
+ }
650
+
651
+ // Tema visual (entrada)
652
+ interface QuizTheme {
653
+ primaryColor: string;
654
+ primaryHoverColor: string;
655
+ textColor: string;
656
+ backgroundColor: string;
657
+ cardBackground: string;
658
+ buttonColor?: string;
659
+ buttonTextColor?: string;
660
+ neutralMid?: string;
661
+ cardBorderColor?: string;
662
+ titleAccentColor?: string;
663
+ titleDarkColor?: string;
664
+ }
665
+
666
+ // Textos i18n (entrada)
667
+ interface QuizTexts {
668
+ title: string;
669
+ subtitle?: string;
670
+ loading: string;
671
+ noItems: string;
672
+ loadMore: string;
673
+ startQuiz: string;
674
+ questionLabel: string; // usa {current} y {total}
675
+ nextButton: string;
676
+ submitButton: string;
677
+ correctAnswer: string;
678
+ wrongAnswer: string;
679
+ retryButton: string;
680
+ closeButton: string;
681
+ scoreLabel: string; // usa {score} y {total}
682
+ previousButton?: string;
683
+ loadingQuestions?: string;
684
+ }
685
+
686
+ // Item del listado (interno)
687
+ interface QuizItem {
688
+ id: string | number;
689
+ title: string;
690
+ description?: string;
691
+ thumbnail?: string;
692
+ contentsId: string; // valor de item.contents
693
+ personality?: boolean; // oculta puntuación, muestra solo resultado
694
+ }
695
+
696
+ // Pregunta individual (interna)
697
+ interface QuizQuestion {
698
+ id: string | number;
699
+ question: string;
700
+ thumbnail?: string;
701
+ options: Array<{ text: string; points: number }>;
702
+ correctIndex?: number; // undefined = quiz por puntos; número = legacy
703
+ explanation?: string; // solo legacy
704
+ maxPoints?: number; // máximo de puntos alcanzable en esta pregunta
705
+ }
706
+
707
+ // Resultado final del quiz (interno)
708
+ interface QuizResult {
709
+ title: string;
710
+ description: string;
711
+ minPoints: number;
712
+ maxPoints: number;
713
+ }
714
+ ```
715
+
716
+ ### `formatLabel(template, vars)` — helper de interpolación
717
+
718
+ Reemplaza `{key}` en un string por su valor correspondiente:
719
+ ```ts
720
+ formatLabel("Pregunta {current} de {total}", { current: 2, total: 5 })
721
+ // → "Pregunta 2 de 5"
722
+ ```
723
+
724
+ Usado en `questionLabel` y `scoreLabel`.
725
+
726
+ ---
727
+
728
+ ## Claves de traducción i18n usadas
729
+
730
+ Namespace: `for_you.quiz_component.*`
731
+
732
+ | Clave | Uso |
733
+ |---|---|
734
+ | `title` | Título del listado |
735
+ | `subtitle` | Subtítulo del listado |
736
+ | `loading` | Texto mientras carga el listado |
737
+ | `no_items` | Texto cuando no hay quizzes |
738
+ | `load_more` | Botón para cargar más |
739
+ | `start_quiz` | Texto del CTA en cada card |
740
+ | `question_label` | Label de progreso: `"Question {current} of {total}"` |
741
+ | `next_button` | Botón siguiente pregunta |
742
+ | `previous_button` | Botón pregunta anterior |
743
+ | `submit_button` | Botón en la última pregunta |
744
+ | `correct_answer` | Feedback correcto (legacy) |
745
+ | `wrong_answer` | Feedback incorrecto (legacy) |
746
+ | `retry_button` | Botón reintentar en resultado |
747
+ | `close_button` | Botón cerrar en resultado |
748
+ | `score_label` | Resultado: `"You got {score}/{total} correct"` |
749
+ | `loading_questions` | Texto mientras carga el modal |
750
+
751
+ ---
752
+
753
+ *Documento generado el 29/06/2026*