webs-sdk 0.19.62 → 0.19.65
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.
- package/AGENTS.md +44 -0
- package/CLAUDE.md +80 -0
- package/README.md +63 -1
- package/dist/components/SpinningWheel/index.d.ts +1 -1
- package/dist/components/SpinningWheel/index.d.ts.map +1 -1
- package/dist/components/SpinningWheel/index.js +2 -1
- package/dist/components/SpinningWheel/index.js.map +1 -1
- package/dist/components/ui/ConfiguredMediaPlayer.d.ts +49 -0
- package/dist/components/ui/ConfiguredMediaPlayer.d.ts.map +1 -0
- package/dist/components/ui/ConfiguredMediaPlayer.js +111 -0
- package/dist/components/ui/ConfiguredMediaPlayer.js.map +1 -0
- package/dist/components/ui/badge.d.ts +1 -0
- package/dist/components/ui/badge.d.ts.map +1 -1
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/badge.js.map +1 -1
- package/dist/components/ui/compose-refs.d.ts +6 -0
- package/dist/components/ui/compose-refs.d.ts.map +1 -0
- package/dist/components/ui/compose-refs.js +57 -0
- package/dist/components/ui/compose-refs.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +3 -1
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
- package/dist/components/ui/dropdown-menu.js +2 -2
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +2 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/components/ui/media-player.d.ts +127 -0
- package/dist/components/ui/media-player.d.ts.map +1 -0
- package/dist/components/ui/media-player.js +1633 -0
- package/dist/components/ui/media-player.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +3 -1
- package/dist/components/ui/tooltip.d.ts.map +1 -1
- package/dist/components/ui/tooltip.js +2 -2
- package/dist/components/ui/tooltip.js.map +1 -1
- package/dist/components/ui/use-lazy-ref.d.ts +4 -0
- package/dist/components/ui/use-lazy-ref.d.ts.map +1 -0
- package/dist/components/ui/use-lazy-ref.js +45 -0
- package/dist/components/ui/use-lazy-ref.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/libraries/auth.d.ts.map +1 -1
- package/dist/libraries/auth.js +2 -2
- package/dist/libraries/auth.js.map +1 -1
- package/dist/libraries/clarity.d.ts +23 -0
- package/dist/libraries/clarity.d.ts.map +1 -0
- package/dist/libraries/clarity.js +110 -0
- package/dist/libraries/clarity.js.map +1 -0
- package/dist/libraries/index.d.ts +1 -0
- package/dist/libraries/index.d.ts.map +1 -1
- package/dist/libraries/index.js +3 -1
- package/dist/libraries/index.js.map +1 -1
- package/dist/libraries/networking.d.ts +4 -0
- package/dist/libraries/networking.d.ts.map +1 -1
- package/dist/libraries/networking.js +42 -6
- package/dist/libraries/networking.js.map +1 -1
- package/dist/libraries/session.d.ts.map +1 -1
- package/dist/libraries/session.js +2 -0
- package/dist/libraries/session.js.map +1 -1
- package/dist/network.d.ts +12 -0
- package/dist/network.d.ts.map +1 -0
- package/dist/network.js +15 -0
- package/dist/network.js.map +1 -0
- package/dist/server.d.ts +54 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +74 -0
- package/dist/server.js.map +1 -0
- package/dist/types/media-chrome-store-types.d.ts +107 -0
- package/dist/types/media-chrome-store-types.d.ts.map +1 -0
- package/dist/types/media-chrome-store-types.js +3 -0
- package/dist/types/media-chrome-store-types.js.map +1 -0
- package/dist/utils/QRCode.d.ts +1 -0
- package/dist/utils/QRCode.d.ts.map +1 -0
- package/dist/utils/QRCode.js +2 -0
- package/dist/utils/QRCode.js.map +1 -0
- package/dist/utils/imageValidation.d.ts +6 -0
- package/dist/utils/imageValidation.d.ts.map +1 -0
- package/dist/utils/imageValidation.js +64 -0
- package/dist/utils/imageValidation.js.map +1 -0
- package/docs/CONFIGURED_MEDIA_PLAYER.md +40 -0
- package/docs/CORP_AUTH_USAGE.md +687 -0
- package/docs/EXAMPLES_WALLPAPERSNAME_IMPLEMENTATION.md +1445 -0
- package/docs/EXAMPLE_ENDPOINTS_COMUNICATION.md +55 -0
- package/docs/EXTERNAL_SDK_GUIDE.md +1340 -0
- package/docs/FOR_YOU_360_FACESWAP_FIX.md +400 -0
- package/docs/FOR_YOU_IMPLEMENTATION_GUIDE.md +1413 -0
- package/docs/FOR_YOU_QUIZ_ARCHITECTURE.md +753 -0
- package/docs/FOR_YOU_SDK_WALLPAPERSNAME_BUG.md +106 -0
- package/docs/FOR_YOU_WALLPAPERSNAME_ENHANCEMENT_PLAN.md +210 -0
- package/docs/LEGAL_USAGE.md +190 -0
- package/docs/STREAK_IMPLEMENTATION_GUIDE.md +430 -0
- package/package.json +29 -6
- package/scripts/install-agent-skill.cjs +34 -0
- package/skills/webs-sdk-package/SKILL.md +11 -0
- package/skills/webs-sdk-package/references/components.md +30 -0
- package/skills/webs-sdk-package/references/integration.md +32 -0
- package/dist/libraries/globals.d.ts +0 -17
- package/dist/libraries/globals.d.ts.map +0 -1
- package/dist/libraries/globals.js +0 -38
- package/dist/libraries/globals.js.map +0 -1
|
@@ -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*
|