@hagicode/hagilight 0.1.0 → 0.1.2-dev.10.1.aebe89d
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/Analytics51LA.astro +17 -0
- package/GoogleAnalytics.astro +21 -0
- package/PromotoBanner.astro +346 -0
- package/logo.png +0 -0
- package/package.json +11 -3
- package/promotions.ts +263 -0
- package/promoto-banner.ts +358 -0
- package/related-sites.json +262 -0
- package/site-links.ts +406 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
siteId: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { siteId } = Astro.props;
|
|
7
|
+
if (!siteId?.trim()) throw new Error('Analytics51LA requires a siteId prop.');
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
{import.meta.env.PROD && (
|
|
11
|
+
<>
|
|
12
|
+
<script is:inline charset="UTF-8" id="LA_COLLECT" src="https://sdk.51.la/js-sdk-pro.min.js"></script>
|
|
13
|
+
<script is:inline define:vars={{ siteId }}>
|
|
14
|
+
LA.init({ id: siteId, ck: siteId, autoTrack: true, hashMode: true, screenRecord: true });
|
|
15
|
+
</script>
|
|
16
|
+
</>
|
|
17
|
+
)}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
measurementId: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { measurementId } = Astro.props;
|
|
7
|
+
if (!measurementId?.trim()) throw new Error('GoogleAnalytics requires a measurementId prop.');
|
|
8
|
+
const scriptUrl = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{import.meta.env.PROD && (
|
|
12
|
+
<>
|
|
13
|
+
<script async src={scriptUrl}></script>
|
|
14
|
+
<script is:inline define:vars={{ measurementId }}>
|
|
15
|
+
window.dataLayer = window.dataLayer || [];
|
|
16
|
+
function gtag(){dataLayer.push(arguments);}
|
|
17
|
+
gtag('js', new Date());
|
|
18
|
+
gtag('config', measurementId);
|
|
19
|
+
</script>
|
|
20
|
+
</>
|
|
21
|
+
)}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { PromotionCard } from './promotions';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
fallback?: PromotionCard;
|
|
6
|
+
locale?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { fallback, locale = Astro.currentLocale ?? 'en' } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<hagilight-promoto-banner
|
|
13
|
+
class="hagilight-promoto"
|
|
14
|
+
data-locale={locale}
|
|
15
|
+
data-fallback={fallback ? JSON.stringify(fallback) : undefined}
|
|
16
|
+
hidden
|
|
17
|
+
>
|
|
18
|
+
<section class="hagilight-promoto__shell" data-promoto-shell hidden aria-label="Featured promotion">
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
class="hagilight-promoto__dismiss"
|
|
22
|
+
data-promoto-dismiss
|
|
23
|
+
aria-label="Dismiss promotion"
|
|
24
|
+
>×</button>
|
|
25
|
+
<div class="hagilight-promoto__viewport">
|
|
26
|
+
<div class="hagilight-promoto__track" data-promoto-track></div>
|
|
27
|
+
</div>
|
|
28
|
+
<nav class="hagilight-promoto__controls" data-promoto-controls aria-label="Promotion controls" hidden>
|
|
29
|
+
<button type="button" class="hagilight-promoto__control" data-promoto-previous aria-label="Previous promotion">‹</button>
|
|
30
|
+
<span class="hagilight-promoto__count" data-promoto-count aria-label="Promotion progress"></span>
|
|
31
|
+
<button type="button" class="hagilight-promoto__control" data-promoto-next aria-label="Next promotion">›</button>
|
|
32
|
+
<button type="button" class="hagilight-promoto__control" data-promoto-pause aria-pressed="false">Pause</button>
|
|
33
|
+
</nav>
|
|
34
|
+
<span class="hagilight-promoto__status" data-promoto-status aria-live="polite"></span>
|
|
35
|
+
</section>
|
|
36
|
+
</hagilight-promoto-banner>
|
|
37
|
+
|
|
38
|
+
<script>
|
|
39
|
+
import { definePromotoBannerElement } from './promoto-banner';
|
|
40
|
+
|
|
41
|
+
definePromotoBannerElement();
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<style is:global>
|
|
45
|
+
.hagilight-promoto {
|
|
46
|
+
display: block;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hagilight-promoto[hidden],
|
|
50
|
+
.hagilight-promoto__shell[hidden],
|
|
51
|
+
.hagilight-promoto__controls[hidden] {
|
|
52
|
+
display: none !important;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.hagilight-promoto__shell {
|
|
56
|
+
position: fixed;
|
|
57
|
+
z-index: 40;
|
|
58
|
+
inset-inline: 0;
|
|
59
|
+
bottom: max(1rem, env(safe-area-inset-bottom));
|
|
60
|
+
width: min(72rem, calc(100vw - 1rem));
|
|
61
|
+
margin-inline: auto;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
border: 1px solid color-mix(in srgb, var(--sl-color-accent, #2563eb) 24%, var(--sl-color-gray-5, #cbd5e1));
|
|
64
|
+
border-radius: 1.25rem;
|
|
65
|
+
background: color-mix(in srgb, var(--sl-color-bg, #fff) 96%, var(--sl-color-accent-low, #dbeafe) 4%);
|
|
66
|
+
box-shadow: 0 18px 48px color-mix(in srgb, var(--sl-color-black, #000) 16%, transparent);
|
|
67
|
+
color: var(--sl-color-text, #1f2937);
|
|
68
|
+
opacity: 1;
|
|
69
|
+
visibility: visible;
|
|
70
|
+
transition: opacity 0.24s ease, visibility 0s linear;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.hagilight-promoto[data-state='footer-hidden'] .hagilight-promoto__shell {
|
|
74
|
+
opacity: 0;
|
|
75
|
+
visibility: hidden;
|
|
76
|
+
pointer-events: none;
|
|
77
|
+
transition: opacity 0.24s ease, visibility 0s linear 0.24s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.hagilight-promoto__viewport {
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.hagilight-promoto__track {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: stretch;
|
|
87
|
+
width: 100%;
|
|
88
|
+
transition: transform 0.34s ease;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.hagilight-promoto__slide {
|
|
92
|
+
display: grid;
|
|
93
|
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: 1.25rem;
|
|
96
|
+
width: 100%;
|
|
97
|
+
min-width: 100%;
|
|
98
|
+
padding: 1.25rem 4rem 1.25rem 1.25rem;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.hagilight-promoto__slide[aria-hidden='true'] {
|
|
102
|
+
pointer-events: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.hagilight-promoto__copy {
|
|
106
|
+
display: grid;
|
|
107
|
+
grid-template-columns: minmax(12rem, 0.8fr) minmax(16rem, 1.2fr);
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: 1rem;
|
|
110
|
+
min-width: 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.hagilight-promoto__title,
|
|
114
|
+
.hagilight-promoto__description {
|
|
115
|
+
display: -webkit-box;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
margin: 0;
|
|
118
|
+
color: var(--sl-color-text-high, var(--sl-color-gray-1, #111827));
|
|
119
|
+
font-size: var(--sl-text-sm);
|
|
120
|
+
font-weight: 600;
|
|
121
|
+
line-height: 1.35;
|
|
122
|
+
-webkit-box-orient: vertical;
|
|
123
|
+
-webkit-line-clamp: 2;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.hagilight-promoto__description {
|
|
127
|
+
color: var(--sl-color-text, #1f2937);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.hagilight-promoto__media {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
justify-content: center;
|
|
134
|
+
width: clamp(4.5rem, 8vw, 6.25rem);
|
|
135
|
+
min-width: clamp(4.5rem, 8vw, 6.25rem);
|
|
136
|
+
aspect-ratio: 1;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
border: 1px solid color-mix(in srgb, var(--sl-color-accent-high, #1d4ed8) 18%, var(--sl-color-gray-5, #cbd5e1));
|
|
139
|
+
border-radius: 0.8rem;
|
|
140
|
+
background: color-mix(in srgb, var(--sl-color-bg-sidebar, #f8fafc) 82%, transparent);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.hagilight-promoto__image {
|
|
144
|
+
display: block;
|
|
145
|
+
width: 100%;
|
|
146
|
+
height: 100%;
|
|
147
|
+
object-fit: cover;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.hagilight-promoto__cta {
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: center;
|
|
154
|
+
min-width: 7.5rem;
|
|
155
|
+
min-height: 2.6rem;
|
|
156
|
+
border: 1px solid color-mix(in srgb, var(--sl-color-accent-high, #1d4ed8) 30%, transparent);
|
|
157
|
+
border-radius: 999px;
|
|
158
|
+
background: var(--sl-color-accent, #2563eb);
|
|
159
|
+
color: #fff;
|
|
160
|
+
font-size: var(--sl-text-xs);
|
|
161
|
+
font-weight: 700;
|
|
162
|
+
padding-inline: 1rem;
|
|
163
|
+
text-align: center;
|
|
164
|
+
text-decoration: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.hagilight-promoto__cta:hover,
|
|
168
|
+
.hagilight-promoto__cta:focus-visible {
|
|
169
|
+
background: var(--sl-color-accent-high, #1d4ed8);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.hagilight-promoto__dismiss,
|
|
173
|
+
.hagilight-promoto__control {
|
|
174
|
+
display: inline-flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
justify-content: center;
|
|
177
|
+
min-width: 2.5rem;
|
|
178
|
+
min-height: 2.5rem;
|
|
179
|
+
border: 1px solid color-mix(in srgb, var(--sl-color-accent-high, #1d4ed8) 20%, var(--sl-color-gray-5, #cbd5e1));
|
|
180
|
+
border-radius: 999px;
|
|
181
|
+
background: var(--sl-color-bg-nav, #f8fafc);
|
|
182
|
+
color: var(--sl-color-text-high, #111827);
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.hagilight-promoto__dismiss {
|
|
187
|
+
position: absolute;
|
|
188
|
+
z-index: 1;
|
|
189
|
+
inset-block-start: 0.6rem;
|
|
190
|
+
inset-inline-end: 0.6rem;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.hagilight-promoto__controls {
|
|
194
|
+
display: flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
justify-content: flex-end;
|
|
197
|
+
gap: 0.5rem;
|
|
198
|
+
padding: 0 1.25rem 1rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.hagilight-promoto__count {
|
|
202
|
+
min-width: 3.5rem;
|
|
203
|
+
text-align: center;
|
|
204
|
+
font-size: var(--sl-text-xs);
|
|
205
|
+
font-variant-numeric: tabular-nums;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.hagilight-promoto__control:disabled {
|
|
209
|
+
cursor: default;
|
|
210
|
+
opacity: 0.6;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.hagilight-promoto__status {
|
|
214
|
+
position: absolute;
|
|
215
|
+
width: 1px;
|
|
216
|
+
height: 1px;
|
|
217
|
+
overflow: hidden;
|
|
218
|
+
clip: rect(0, 0, 0, 0);
|
|
219
|
+
white-space: nowrap;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.hagilight-promoto__dismiss:focus-visible,
|
|
223
|
+
.hagilight-promoto__control:focus-visible,
|
|
224
|
+
.hagilight-promoto__cta:focus-visible {
|
|
225
|
+
outline: 3px solid var(--sl-color-accent-high, #1d4ed8);
|
|
226
|
+
outline-offset: 3px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
@media (max-width: 960px) {
|
|
230
|
+
.hagilight-promoto__slide {
|
|
231
|
+
grid-template-columns: 1fr;
|
|
232
|
+
gap: 0.75rem;
|
|
233
|
+
padding-inline-end: 3.7rem;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.hagilight-promoto__slide[data-has-image='true'] {
|
|
237
|
+
grid-template-columns: 1fr;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.hagilight-promoto__copy {
|
|
241
|
+
grid-template-columns: 1fr;
|
|
242
|
+
gap: 0.45rem;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.hagilight-promoto__media {
|
|
246
|
+
width: min(100%, 8rem);
|
|
247
|
+
min-width: 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.hagilight-promoto__cta {
|
|
251
|
+
justify-self: start;
|
|
252
|
+
white-space: normal;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.hagilight-promoto__controls {
|
|
256
|
+
justify-content: flex-start;
|
|
257
|
+
padding-top: 0;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@media (max-width: 720px) {
|
|
262
|
+
.hagilight-promoto__shell {
|
|
263
|
+
bottom: calc(env(safe-area-inset-bottom) + 0.5rem);
|
|
264
|
+
width: min(calc(100vw - 1rem), 24rem);
|
|
265
|
+
border-radius: 1rem;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.hagilight-promoto__slide {
|
|
269
|
+
grid-template-columns: 1fr;
|
|
270
|
+
gap: 0.7rem;
|
|
271
|
+
padding: 0.85rem 4.8rem 0.85rem 0.85rem;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.hagilight-promoto__slide[data-has-image='true'] {
|
|
275
|
+
grid-template-columns: minmax(0, 1fr) 4.5rem;
|
|
276
|
+
align-items: start;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.hagilight-promoto__copy {
|
|
280
|
+
grid-column: 1;
|
|
281
|
+
grid-row: 1;
|
|
282
|
+
gap: 0.45rem;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.hagilight-promoto__title {
|
|
286
|
+
font-size: 0.92rem;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.hagilight-promoto__description {
|
|
290
|
+
font-size: 0.84rem;
|
|
291
|
+
font-weight: 500;
|
|
292
|
+
line-height: 1.3;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.hagilight-promoto__media {
|
|
296
|
+
width: 4.5rem;
|
|
297
|
+
min-width: 4.5rem;
|
|
298
|
+
align-self: start;
|
|
299
|
+
grid-column: 2;
|
|
300
|
+
grid-row: 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.hagilight-promoto__cta {
|
|
304
|
+
grid-column: 1 / -1;
|
|
305
|
+
justify-self: stretch;
|
|
306
|
+
min-width: 0;
|
|
307
|
+
min-height: 2.35rem;
|
|
308
|
+
width: 100%;
|
|
309
|
+
font-size: 0.82rem;
|
|
310
|
+
white-space: normal;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.hagilight-promoto__control {
|
|
314
|
+
min-width: 2.2rem;
|
|
315
|
+
min-height: 2.2rem;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.hagilight-promoto__count {
|
|
319
|
+
min-width: 2.75rem;
|
|
320
|
+
font-size: 0.75rem;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.hagilight-promoto__dismiss {
|
|
324
|
+
inset-block-start: 0.55rem;
|
|
325
|
+
inset-inline-end: 0.55rem;
|
|
326
|
+
width: auto;
|
|
327
|
+
min-width: 2rem;
|
|
328
|
+
height: 2rem;
|
|
329
|
+
gap: 0.25rem;
|
|
330
|
+
padding-inline: 0.55rem;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.hagilight-promoto__controls {
|
|
334
|
+
flex-wrap: wrap;
|
|
335
|
+
gap: 0.35rem;
|
|
336
|
+
padding: 0 0.85rem 0.9rem;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
@media (prefers-reduced-motion: reduce) {
|
|
341
|
+
.hagilight-promoto__shell,
|
|
342
|
+
.hagilight-promoto__track {
|
|
343
|
+
transition: none;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
</style>
|
package/logo.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2-dev.10.1.aebe89d",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared Astro components for HagiCode sites",
|
|
6
6
|
"repository": {
|
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
"provenance": true
|
|
14
14
|
},
|
|
15
15
|
"exports": {
|
|
16
|
-
"./Copyright": "./Copyright.astro"
|
|
16
|
+
"./Copyright": "./Copyright.astro",
|
|
17
|
+
"./PromotoBanner": "./PromotoBanner.astro",
|
|
18
|
+
"./logo.png": "./logo.png",
|
|
19
|
+
"./site-links": "./site-links.ts",
|
|
20
|
+
"./GoogleAnalytics": "./GoogleAnalytics.astro",
|
|
21
|
+
"./Analytics51LA": "./Analytics51LA.astro"
|
|
17
22
|
},
|
|
18
23
|
"files": [
|
|
19
|
-
"*.astro"
|
|
24
|
+
"*.astro",
|
|
25
|
+
"*.png",
|
|
26
|
+
"*.ts",
|
|
27
|
+
"*.json"
|
|
20
28
|
],
|
|
21
29
|
"peerDependencies": {
|
|
22
30
|
"astro": "^7.3.5"
|
package/promotions.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
const INDEX_ORIGIN = 'https://index.hagicode.com';
|
|
2
|
+
const CATALOG_URL = `${INDEX_ORIGIN}/index-catalog.json`;
|
|
3
|
+
const FLAGS_URL = `${INDEX_ORIGIN}/promote.json`;
|
|
4
|
+
const CONTENT_URL = `${INDEX_ORIGIN}/promote_content.json`;
|
|
5
|
+
|
|
6
|
+
type JsonRecord = Record<string, unknown>;
|
|
7
|
+
type FetchLike = typeof fetch;
|
|
8
|
+
|
|
9
|
+
export interface PromotionImage {
|
|
10
|
+
src: string;
|
|
11
|
+
alt: string;
|
|
12
|
+
variant?: string;
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PromotionCard {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
ctaLabel: string;
|
|
22
|
+
link: string;
|
|
23
|
+
image?: PromotionImage;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PromotionFlag {
|
|
27
|
+
id: string;
|
|
28
|
+
on: boolean;
|
|
29
|
+
startTime?: string;
|
|
30
|
+
endTime?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PromotionContent {
|
|
34
|
+
id: string;
|
|
35
|
+
title: Record<string, string>;
|
|
36
|
+
description: Record<string, string>;
|
|
37
|
+
cta?: Record<string, string>;
|
|
38
|
+
link: string;
|
|
39
|
+
image?: PromotionImage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nonEmptyString(value: unknown): value is string {
|
|
47
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseLocalizedMap(value: unknown): Record<string, string> | null {
|
|
51
|
+
if (!isRecord(value)) return null;
|
|
52
|
+
const entries = Object.entries(value).filter(
|
|
53
|
+
(entry): entry is [string, string] => nonEmptyString(entry[0]) && nonEmptyString(entry[1]),
|
|
54
|
+
);
|
|
55
|
+
return entries.length ? Object.fromEntries(entries.map(([key, text]) => [key.trim(), text.trim()])) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseImage(value: unknown, record: JsonRecord): PromotionImage | undefined {
|
|
59
|
+
const image = isRecord(value) ? value : {};
|
|
60
|
+
const src = nonEmptyString(value)
|
|
61
|
+
? value.trim()
|
|
62
|
+
: nonEmptyString(image.src)
|
|
63
|
+
? image.src.trim()
|
|
64
|
+
: nonEmptyString(image.url)
|
|
65
|
+
? image.url.trim()
|
|
66
|
+
: nonEmptyString(record.imageUrl)
|
|
67
|
+
? record.imageUrl.trim()
|
|
68
|
+
: nonEmptyString(record.imageURL)
|
|
69
|
+
? record.imageURL.trim()
|
|
70
|
+
: '';
|
|
71
|
+
if (!src) return undefined;
|
|
72
|
+
|
|
73
|
+
const width = typeof image.width === 'number' && Number.isFinite(image.width) && image.width > 0
|
|
74
|
+
? Math.round(image.width)
|
|
75
|
+
: undefined;
|
|
76
|
+
const height = typeof image.height === 'number' && Number.isFinite(image.height) && image.height > 0
|
|
77
|
+
? Math.round(image.height)
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
let normalizedSrc: string;
|
|
81
|
+
try {
|
|
82
|
+
const url = new URL(src, INDEX_ORIGIN);
|
|
83
|
+
if (!['http:', 'https:'].includes(url.protocol)) return undefined;
|
|
84
|
+
normalizedSrc = url.toString();
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
src: normalizedSrc,
|
|
91
|
+
alt: nonEmptyString(image.alt)
|
|
92
|
+
? image.alt.trim()
|
|
93
|
+
: nonEmptyString(record.imageAlt)
|
|
94
|
+
? record.imageAlt.trim()
|
|
95
|
+
: '',
|
|
96
|
+
variant: nonEmptyString(image.variant) ? image.variant.trim() : undefined,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isSafeLink(value: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
return ['http:', 'https:'].includes(new URL(value, INDEX_ORIGIN).protocol);
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parsePromotionFlags(payload: unknown): PromotionFlag[] {
|
|
111
|
+
if (!isRecord(payload) || !Array.isArray(payload.promotes)) return [];
|
|
112
|
+
return payload.promotes.flatMap((item): PromotionFlag[] => {
|
|
113
|
+
if (!isRecord(item) || !nonEmptyString(item.id) || typeof item.on !== 'boolean') return [];
|
|
114
|
+
return [{
|
|
115
|
+
id: item.id.trim(),
|
|
116
|
+
on: item.on,
|
|
117
|
+
startTime: nonEmptyString(item.startTime) ? item.startTime.trim() : undefined,
|
|
118
|
+
endTime: nonEmptyString(item.endTime) ? item.endTime.trim() : undefined,
|
|
119
|
+
}];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parsePromotionContent(payload: unknown): PromotionContent[] {
|
|
124
|
+
if (!isRecord(payload) || !Array.isArray(payload.contents)) return [];
|
|
125
|
+
return payload.contents.flatMap((item): PromotionContent[] => {
|
|
126
|
+
if (!isRecord(item) || !nonEmptyString(item.id) || !nonEmptyString(item.link) || !isSafeLink(item.link)) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const title = parseLocalizedMap(item.title);
|
|
130
|
+
const description = parseLocalizedMap(item.description);
|
|
131
|
+
if (!title || !description) return [];
|
|
132
|
+
const cta = parseLocalizedMap(item.cta);
|
|
133
|
+
const image = parseImage(item.image, item);
|
|
134
|
+
return [{
|
|
135
|
+
id: item.id.trim(),
|
|
136
|
+
title,
|
|
137
|
+
description,
|
|
138
|
+
cta: cta ?? undefined,
|
|
139
|
+
link: item.link.trim(),
|
|
140
|
+
image,
|
|
141
|
+
}];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function localeCandidates(locale: string | null | undefined): string[] {
|
|
146
|
+
let canonical = 'en-US';
|
|
147
|
+
if (locale?.trim()) {
|
|
148
|
+
try {
|
|
149
|
+
canonical = Intl.getCanonicalLocales(locale.trim().replace(/_/g, '-'))[0] ?? 'en-US';
|
|
150
|
+
} catch {
|
|
151
|
+
canonical = 'en-US';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const normalized = canonical.toLowerCase();
|
|
155
|
+
const candidates = [normalized, normalized.split('-')[0] ?? normalized];
|
|
156
|
+
if (normalized.startsWith('zh-hant') || ['zh-tw', 'zh-hk', 'zh-mo'].includes(normalized)) {
|
|
157
|
+
candidates.push('zh-hant', 'zh');
|
|
158
|
+
} else if (normalized.startsWith('zh')) {
|
|
159
|
+
candidates.push('zh-cn', 'zh');
|
|
160
|
+
}
|
|
161
|
+
candidates.push('en-us', 'en');
|
|
162
|
+
return [...new Set(candidates)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function localized(value: Record<string, string>, locale: string | null | undefined): string | null {
|
|
166
|
+
const values = new Map(Object.entries(value).map(([key, text]) => [key.toLowerCase(), text]));
|
|
167
|
+
for (const candidate of localeCandidates(locale)) {
|
|
168
|
+
const result = values.get(candidate);
|
|
169
|
+
if (result) return result;
|
|
170
|
+
}
|
|
171
|
+
return Object.values(value)[0] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isActive(flag: PromotionFlag, now: number): boolean {
|
|
175
|
+
if (!flag.on) return false;
|
|
176
|
+
const start = flag.startTime ? Date.parse(flag.startTime) : null;
|
|
177
|
+
const end = flag.endTime ? Date.parse(flag.endTime) : null;
|
|
178
|
+
if ((start !== null && !Number.isFinite(start)) || (end !== null && !Number.isFinite(end))) return false;
|
|
179
|
+
if (start !== null && end !== null && start >= end) return false;
|
|
180
|
+
return (start === null || now >= start) && (end === null || now < end);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function normalizeActivePromotions(
|
|
184
|
+
flags: PromotionFlag[],
|
|
185
|
+
content: PromotionContent[],
|
|
186
|
+
locale?: string | null,
|
|
187
|
+
now = Date.now(),
|
|
188
|
+
): PromotionCard[] {
|
|
189
|
+
const contentById = new Map(content.map((item) => [item.id, item]));
|
|
190
|
+
return flags.flatMap((flag): PromotionCard[] => {
|
|
191
|
+
const item = contentById.get(flag.id);
|
|
192
|
+
if (!isActive(flag, now) || !item) return [];
|
|
193
|
+
const title = localized(item.title, locale);
|
|
194
|
+
const description = localized(item.description, locale);
|
|
195
|
+
if (!title || !description) return [];
|
|
196
|
+
return [{
|
|
197
|
+
id: item.id,
|
|
198
|
+
title,
|
|
199
|
+
description,
|
|
200
|
+
ctaLabel: (item.cta && localized(item.cta, locale)) || 'Learn more',
|
|
201
|
+
link: item.link,
|
|
202
|
+
image: item.image ? { ...item.image, alt: item.image.alt || title } : undefined,
|
|
203
|
+
}];
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function readJson(fetchImpl: FetchLike, url: string): Promise<unknown> {
|
|
208
|
+
const response = await fetchImpl(url, { headers: { accept: 'application/json' }, cache: 'no-store' });
|
|
209
|
+
if (!response.ok) throw new Error(`Failed to load promotion data (${response.status})`);
|
|
210
|
+
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
|
211
|
+
if (!contentType.includes('json')) throw new Error(`Expected JSON promotion data, received ${contentType || 'no content type'}`);
|
|
212
|
+
return response.json();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function resolvePromotionDocumentUrls(fetchImpl: FetchLike = fetch): Promise<{
|
|
216
|
+
flagsUrl: string;
|
|
217
|
+
contentUrl: string;
|
|
218
|
+
source: 'catalog' | 'fallback';
|
|
219
|
+
}> {
|
|
220
|
+
try {
|
|
221
|
+
const catalog = await readJson(fetchImpl, CATALOG_URL);
|
|
222
|
+
if (isRecord(catalog) && Array.isArray(catalog.entries)) {
|
|
223
|
+
const entries = catalog.entries.filter(isRecord);
|
|
224
|
+
const flags = entries.find((entry) => entry.id === 'promotion-flags' && nonEmptyString(entry.path));
|
|
225
|
+
const content = entries.find((entry) => entry.id === 'promotion-content' && nonEmptyString(entry.path));
|
|
226
|
+
const flagsPath = flags?.path;
|
|
227
|
+
const contentPath = content?.path;
|
|
228
|
+
if (nonEmptyString(flagsPath) && nonEmptyString(contentPath)) {
|
|
229
|
+
const flagsUrl = new URL(flagsPath, INDEX_ORIGIN);
|
|
230
|
+
const contentUrl = new URL(contentPath, INDEX_ORIGIN);
|
|
231
|
+
if (flagsUrl.origin !== INDEX_ORIGIN || contentUrl.origin !== INDEX_ORIGIN) {
|
|
232
|
+
return { flagsUrl: FLAGS_URL, contentUrl: CONTENT_URL, source: 'fallback' };
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
flagsUrl: flagsUrl.toString(),
|
|
236
|
+
contentUrl: contentUrl.toString(),
|
|
237
|
+
source: 'catalog',
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// The stable canonical endpoints remain available when catalog discovery fails.
|
|
243
|
+
}
|
|
244
|
+
return { flagsUrl: FLAGS_URL, contentUrl: CONTENT_URL, source: 'fallback' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function loadActivePromotions(options: {
|
|
248
|
+
locale?: string | null;
|
|
249
|
+
fetchImpl?: FetchLike;
|
|
250
|
+
now?: number;
|
|
251
|
+
} = {}): Promise<PromotionCard[]> {
|
|
252
|
+
const { locale, fetchImpl = fetch, now = Date.now() } = options;
|
|
253
|
+
try {
|
|
254
|
+
const urls = await resolvePromotionDocumentUrls(fetchImpl);
|
|
255
|
+
const [flags, content] = await Promise.all([
|
|
256
|
+
readJson(fetchImpl, urls.flagsUrl),
|
|
257
|
+
readJson(fetchImpl, urls.contentUrl),
|
|
258
|
+
]);
|
|
259
|
+
return normalizeActivePromotions(parsePromotionFlags(flags), parsePromotionContent(content), locale, now);
|
|
260
|
+
} catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
}
|