@hagicode/hagilight 0.1.2-dev.3.1.1c174fd → 0.1.2-dev.4.1.974ccd5
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/PromotoBanner.astro +281 -0
- package/package.json +5 -3
- package/promotions.ts +263 -0
- package/promoto-banner.ts +354 -0
|
@@ -0,0 +1,281 @@
|
|
|
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
|
+
margin: 0;
|
|
116
|
+
color: var(--sl-color-text-high, #111827);
|
|
117
|
+
font-size: var(--sl-text-sm);
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
line-height: 1.35;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.hagilight-promoto__description {
|
|
123
|
+
color: var(--sl-color-text, #1f2937);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.hagilight-promoto__image {
|
|
127
|
+
display: block;
|
|
128
|
+
width: clamp(4.5rem, 8vw, 6.25rem);
|
|
129
|
+
aspect-ratio: 1;
|
|
130
|
+
border-radius: 0.8rem;
|
|
131
|
+
object-fit: cover;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.hagilight-promoto__cta {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
justify-content: center;
|
|
138
|
+
min-width: 7.5rem;
|
|
139
|
+
min-height: 2.6rem;
|
|
140
|
+
border: 1px solid color-mix(in srgb, var(--sl-color-accent-high, #1d4ed8) 30%, transparent);
|
|
141
|
+
border-radius: 999px;
|
|
142
|
+
background: var(--sl-color-accent, #2563eb);
|
|
143
|
+
color: #fff;
|
|
144
|
+
font-size: var(--sl-text-xs);
|
|
145
|
+
font-weight: 700;
|
|
146
|
+
padding-inline: 1rem;
|
|
147
|
+
text-align: center;
|
|
148
|
+
text-decoration: none;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.hagilight-promoto__cta:hover,
|
|
152
|
+
.hagilight-promoto__cta:focus-visible {
|
|
153
|
+
background: var(--sl-color-accent-high, #1d4ed8);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.hagilight-promoto__dismiss,
|
|
157
|
+
.hagilight-promoto__control {
|
|
158
|
+
display: inline-flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: center;
|
|
161
|
+
min-width: 2.5rem;
|
|
162
|
+
min-height: 2.5rem;
|
|
163
|
+
border: 1px solid color-mix(in srgb, var(--sl-color-accent-high, #1d4ed8) 20%, var(--sl-color-gray-5, #cbd5e1));
|
|
164
|
+
border-radius: 999px;
|
|
165
|
+
background: var(--sl-color-bg-nav, #f8fafc);
|
|
166
|
+
color: var(--sl-color-text-high, #111827);
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.hagilight-promoto__dismiss {
|
|
171
|
+
position: absolute;
|
|
172
|
+
z-index: 1;
|
|
173
|
+
inset-block-start: 0.6rem;
|
|
174
|
+
inset-inline-end: 0.6rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.hagilight-promoto__controls {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
justify-content: flex-end;
|
|
181
|
+
gap: 0.5rem;
|
|
182
|
+
padding: 0 1.25rem 1rem;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.hagilight-promoto__count {
|
|
186
|
+
min-width: 3.5rem;
|
|
187
|
+
text-align: center;
|
|
188
|
+
font-size: var(--sl-text-xs);
|
|
189
|
+
font-variant-numeric: tabular-nums;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.hagilight-promoto__control:disabled {
|
|
193
|
+
cursor: default;
|
|
194
|
+
opacity: 0.6;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.hagilight-promoto__status {
|
|
198
|
+
position: absolute;
|
|
199
|
+
width: 1px;
|
|
200
|
+
height: 1px;
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
clip: rect(0, 0, 0, 0);
|
|
203
|
+
white-space: nowrap;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.hagilight-promoto__dismiss:focus-visible,
|
|
207
|
+
.hagilight-promoto__control:focus-visible,
|
|
208
|
+
.hagilight-promoto__cta:focus-visible {
|
|
209
|
+
outline: 3px solid var(--sl-color-accent-high, #1d4ed8);
|
|
210
|
+
outline-offset: 3px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@media (max-width: 960px) {
|
|
214
|
+
.hagilight-promoto__slide {
|
|
215
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
216
|
+
gap: 0.75rem;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.hagilight-promoto__copy {
|
|
220
|
+
grid-template-columns: 1fr;
|
|
221
|
+
gap: 0.45rem;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@media (max-width: 720px) {
|
|
226
|
+
.hagilight-promoto__shell {
|
|
227
|
+
bottom: calc(env(safe-area-inset-bottom) + 0.5rem);
|
|
228
|
+
width: min(calc(100vw - 1rem), 28rem);
|
|
229
|
+
border-radius: 1rem;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.hagilight-promoto__slide {
|
|
233
|
+
grid-template-columns: minmax(0, 1fr) 4rem;
|
|
234
|
+
gap: 0.7rem;
|
|
235
|
+
padding: 0.85rem 3.5rem 0.85rem 0.85rem;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.hagilight-promoto__copy {
|
|
239
|
+
grid-column: 1 / -1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.hagilight-promoto__title {
|
|
243
|
+
font-size: var(--sl-text-xs);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.hagilight-promoto__description {
|
|
247
|
+
display: -webkit-box;
|
|
248
|
+
overflow: hidden;
|
|
249
|
+
font-size: var(--sl-text-xs);
|
|
250
|
+
font-weight: 500;
|
|
251
|
+
-webkit-box-orient: vertical;
|
|
252
|
+
-webkit-line-clamp: 2;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.hagilight-promoto__image {
|
|
256
|
+
grid-column: 2;
|
|
257
|
+
grid-row: 1;
|
|
258
|
+
width: 4rem;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.hagilight-promoto__cta {
|
|
262
|
+
grid-column: 1 / -1;
|
|
263
|
+
width: 100%;
|
|
264
|
+
min-height: 2.3rem;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.hagilight-promoto__controls {
|
|
268
|
+
justify-content: flex-start;
|
|
269
|
+
flex-wrap: wrap;
|
|
270
|
+
gap: 0.35rem;
|
|
271
|
+
padding: 0 0.85rem 0.85rem;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@media (prefers-reduced-motion: reduce) {
|
|
276
|
+
.hagilight-promoto__shell,
|
|
277
|
+
.hagilight-promoto__track {
|
|
278
|
+
transition: none;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight",
|
|
3
|
-
"version": "0.1.2-dev.
|
|
3
|
+
"version": "0.1.2-dev.4.1.974ccd5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared Astro components for HagiCode sites",
|
|
6
6
|
"repository": {
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
"provenance": true
|
|
14
14
|
},
|
|
15
15
|
"exports": {
|
|
16
|
-
"./Copyright": "./Copyright.astro"
|
|
16
|
+
"./Copyright": "./Copyright.astro",
|
|
17
|
+
"./PromotoBanner": "./PromotoBanner.astro"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
|
-
"*.astro"
|
|
20
|
+
"*.astro",
|
|
21
|
+
"*.ts"
|
|
20
22
|
],
|
|
21
23
|
"peerDependencies": {
|
|
22
24
|
"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
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { loadActivePromotions, type PromotionCard } from './promotions.ts';
|
|
2
|
+
|
|
3
|
+
const ELEMENT_NAME = 'hagilight-promoto-banner';
|
|
4
|
+
const DISMISSED_SIGNATURE_KEY = 'hagilight:promoto-banner:dismissed-signature';
|
|
5
|
+
const ROTATION_INTERVAL_MS = 8000;
|
|
6
|
+
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
export type PromotionVisibility = 'dismissed' | 'footer-hidden' | 'hidden' | 'ready';
|
|
9
|
+
|
|
10
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isSafeUrl(value: string): boolean {
|
|
15
|
+
try {
|
|
16
|
+
return ['http:', 'https:'].includes(new URL(value, 'https://hagilight.invalid/').protocol);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isUsableCard(value: unknown): value is PromotionCard {
|
|
23
|
+
if (!isRecord(value)) return false;
|
|
24
|
+
const image = value.image;
|
|
25
|
+
const imageIsValid = image === undefined || (
|
|
26
|
+
isRecord(image) && typeof image.src === 'string' && isSafeUrl(image.src)
|
|
27
|
+
&& typeof image.alt === 'string'
|
|
28
|
+
&& (image.variant === undefined || typeof image.variant === 'string')
|
|
29
|
+
&& (image.width === undefined || (typeof image.width === 'number' && Number.isFinite(image.width) && image.width > 0))
|
|
30
|
+
&& (image.height === undefined || (typeof image.height === 'number' && Number.isFinite(image.height) && image.height > 0))
|
|
31
|
+
);
|
|
32
|
+
return typeof value.id === 'string' && value.id.trim().length > 0
|
|
33
|
+
&& typeof value.title === 'string' && value.title.trim().length > 0
|
|
34
|
+
&& typeof value.description === 'string' && value.description.trim().length > 0
|
|
35
|
+
&& typeof value.ctaLabel === 'string' && value.ctaLabel.trim().length > 0
|
|
36
|
+
&& typeof value.link === 'string' && value.link.trim().length > 0 && isSafeUrl(value.link)
|
|
37
|
+
&& imageIsValid;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function selectPromotionCards(
|
|
41
|
+
remote: PromotionCard[],
|
|
42
|
+
fallback?: PromotionCard | null,
|
|
43
|
+
): PromotionCard[] {
|
|
44
|
+
const eligibleRemote = remote.filter(isUsableCard);
|
|
45
|
+
if (eligibleRemote.length) return eligibleRemote;
|
|
46
|
+
return fallback && isUsableCard(fallback) ? [fallback] : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getPromotionSetSignature(cards: PromotionCard[]): string | null {
|
|
50
|
+
if (!cards.length) return null;
|
|
51
|
+
return JSON.stringify(cards.map(({ id, title, description, ctaLabel, link, image }) => [
|
|
52
|
+
id, title, description, ctaLabel, link, image?.src ?? '', image?.alt ?? '',
|
|
53
|
+
image?.variant ?? '', image?.width ?? 0, image?.height ?? 0,
|
|
54
|
+
]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getPromotionVisibility(
|
|
58
|
+
cards: PromotionCard[],
|
|
59
|
+
signature: string | null,
|
|
60
|
+
dismissedSignature: string | null,
|
|
61
|
+
footerVisible: boolean,
|
|
62
|
+
): PromotionVisibility {
|
|
63
|
+
if (!cards.length) return 'hidden';
|
|
64
|
+
if (signature && signature === dismissedSignature) return 'dismissed';
|
|
65
|
+
return footerVisible ? 'footer-hidden' : 'ready';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getNextPromotionIndex(index: number, count: number, direction: -1 | 1): number {
|
|
69
|
+
return count > 0 ? (index + direction + count) % count : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function shouldAutoRotate(
|
|
73
|
+
count: number,
|
|
74
|
+
paused: boolean,
|
|
75
|
+
reducedMotion: boolean,
|
|
76
|
+
documentVisible: boolean,
|
|
77
|
+
visibility: PromotionVisibility,
|
|
78
|
+
): boolean {
|
|
79
|
+
return count > 1 && !paused && !reducedMotion && documentVisible && visibility === 'ready';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readDismissedSignature(): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return window.localStorage.getItem(DISMISSED_SIGNATURE_KEY);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function saveDismissedSignature(signature: string): void {
|
|
91
|
+
try {
|
|
92
|
+
window.localStorage.setItem(DISMISSED_SIGNATURE_KEY, signature);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.warn('Unable to save the dismissed promotion preference.', error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function definePromotoBannerElement(): void {
|
|
99
|
+
if (typeof customElements === 'undefined' || typeof HTMLElement === 'undefined'
|
|
100
|
+
|| customElements.get(ELEMENT_NAME)) return;
|
|
101
|
+
|
|
102
|
+
class PromotoBannerElement extends HTMLElement {
|
|
103
|
+
private cards: PromotionCard[] = [];
|
|
104
|
+
private currentIndex = 0;
|
|
105
|
+
private dismissedSignature = readDismissedSignature();
|
|
106
|
+
private paused = false;
|
|
107
|
+
private reducedMotion = false;
|
|
108
|
+
private rotationTimer: number | undefined;
|
|
109
|
+
private refreshTimer: number | undefined;
|
|
110
|
+
private motionQuery: MediaQueryList | undefined;
|
|
111
|
+
private resizeObserver: ResizeObserver | undefined;
|
|
112
|
+
private footer: HTMLElement | null = null;
|
|
113
|
+
|
|
114
|
+
private readonly onPrevious = () => this.move(-1);
|
|
115
|
+
private readonly onNext = () => this.move(1);
|
|
116
|
+
private readonly onPause = () => {
|
|
117
|
+
if (this.reducedMotion) return;
|
|
118
|
+
this.paused = !this.paused;
|
|
119
|
+
this.updateControls();
|
|
120
|
+
this.syncRotation();
|
|
121
|
+
};
|
|
122
|
+
private readonly onDismiss = () => {
|
|
123
|
+
const signature = getPromotionSetSignature(this.cards);
|
|
124
|
+
if (!signature) return;
|
|
125
|
+
this.dismissedSignature = signature;
|
|
126
|
+
saveDismissedSignature(signature);
|
|
127
|
+
this.updateVisibility();
|
|
128
|
+
this.stopRotation();
|
|
129
|
+
};
|
|
130
|
+
private readonly onLayoutChange = () => {
|
|
131
|
+
const previousState = this.dataset.state;
|
|
132
|
+
this.updateVisibility();
|
|
133
|
+
if (previousState !== this.dataset.state) this.syncRotation();
|
|
134
|
+
};
|
|
135
|
+
private readonly onVisibilityChange = () => {
|
|
136
|
+
if (document.visibilityState === 'hidden') {
|
|
137
|
+
this.stopRotation();
|
|
138
|
+
this.stopRefresh();
|
|
139
|
+
} else {
|
|
140
|
+
this.syncRotation();
|
|
141
|
+
this.startRefresh();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
private readonly onMotionChange = () => {
|
|
145
|
+
this.reducedMotion = this.motionQuery?.matches ?? false;
|
|
146
|
+
this.updateControls();
|
|
147
|
+
this.syncRotation();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
connectedCallback(): void {
|
|
151
|
+
this.footer = document.querySelector('footer');
|
|
152
|
+
this.motionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
153
|
+
this.reducedMotion = this.motionQuery?.matches ?? false;
|
|
154
|
+
this.querySelector('[data-promoto-previous]')?.addEventListener('click', this.onPrevious);
|
|
155
|
+
this.querySelector('[data-promoto-next]')?.addEventListener('click', this.onNext);
|
|
156
|
+
this.querySelector('[data-promoto-pause]')?.addEventListener('click', this.onPause);
|
|
157
|
+
this.querySelector('[data-promoto-dismiss]')?.addEventListener('click', this.onDismiss);
|
|
158
|
+
window.addEventListener('scroll', this.onLayoutChange, { passive: true });
|
|
159
|
+
window.addEventListener('resize', this.onLayoutChange);
|
|
160
|
+
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
161
|
+
this.motionQuery?.addEventListener('change', this.onMotionChange);
|
|
162
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
163
|
+
this.resizeObserver = new ResizeObserver(this.onLayoutChange);
|
|
164
|
+
if (this.footer) this.resizeObserver.observe(this.footer);
|
|
165
|
+
const shell = this.querySelector('[data-promoto-shell]');
|
|
166
|
+
if (shell) this.resizeObserver.observe(shell);
|
|
167
|
+
}
|
|
168
|
+
void this.reload();
|
|
169
|
+
this.startRefresh();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
disconnectedCallback(): void {
|
|
173
|
+
this.querySelector('[data-promoto-previous]')?.removeEventListener('click', this.onPrevious);
|
|
174
|
+
this.querySelector('[data-promoto-next]')?.removeEventListener('click', this.onNext);
|
|
175
|
+
this.querySelector('[data-promoto-pause]')?.removeEventListener('click', this.onPause);
|
|
176
|
+
this.querySelector('[data-promoto-dismiss]')?.removeEventListener('click', this.onDismiss);
|
|
177
|
+
window.removeEventListener('scroll', this.onLayoutChange);
|
|
178
|
+
window.removeEventListener('resize', this.onLayoutChange);
|
|
179
|
+
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
180
|
+
this.motionQuery?.removeEventListener('change', this.onMotionChange);
|
|
181
|
+
this.resizeObserver?.disconnect();
|
|
182
|
+
this.stopRotation();
|
|
183
|
+
this.stopRefresh();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async reload(): Promise<void> {
|
|
187
|
+
const previousId = this.cards[this.currentIndex]?.id;
|
|
188
|
+
const remote = await loadActivePromotions({ locale: this.dataset.locale });
|
|
189
|
+
if (!this.isConnected) return;
|
|
190
|
+
const fallback = this.readFallback();
|
|
191
|
+
this.cards = selectPromotionCards(remote, fallback);
|
|
192
|
+
const previousIndex = previousId ? this.cards.findIndex((card) => card.id === previousId) : -1;
|
|
193
|
+
this.currentIndex = previousIndex >= 0 ? previousIndex : 0;
|
|
194
|
+
this.renderCards();
|
|
195
|
+
this.updateVisibility();
|
|
196
|
+
this.updateControls();
|
|
197
|
+
this.syncRotation();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private readFallback(): PromotionCard | null {
|
|
201
|
+
const serialized = this.dataset.fallback;
|
|
202
|
+
if (!serialized) return null;
|
|
203
|
+
try {
|
|
204
|
+
const value: unknown = JSON.parse(serialized);
|
|
205
|
+
return isUsableCard(value) ? value : null;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.warn('Unable to parse the site-provided promotion fallback.', error);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private renderCards(): void {
|
|
213
|
+
const track = this.querySelector<HTMLElement>('[data-promoto-track]');
|
|
214
|
+
if (!track) return;
|
|
215
|
+
const fragment = document.createDocumentFragment();
|
|
216
|
+
this.cards.forEach((card, index) => {
|
|
217
|
+
const slide = document.createElement('article');
|
|
218
|
+
slide.className = 'hagilight-promoto__slide';
|
|
219
|
+
slide.setAttribute('aria-hidden', index === this.currentIndex ? 'false' : 'true');
|
|
220
|
+
if (index !== this.currentIndex) slide.setAttribute('inert', '');
|
|
221
|
+
|
|
222
|
+
const copy = document.createElement('div');
|
|
223
|
+
copy.className = 'hagilight-promoto__copy';
|
|
224
|
+
const title = document.createElement('h2');
|
|
225
|
+
title.className = 'hagilight-promoto__title';
|
|
226
|
+
title.textContent = card.title;
|
|
227
|
+
const description = document.createElement('p');
|
|
228
|
+
description.className = 'hagilight-promoto__description';
|
|
229
|
+
description.textContent = card.description;
|
|
230
|
+
copy.append(title, description);
|
|
231
|
+
|
|
232
|
+
const link = document.createElement('a');
|
|
233
|
+
link.className = 'hagilight-promoto__cta';
|
|
234
|
+
link.href = card.link;
|
|
235
|
+
link.target = '_blank';
|
|
236
|
+
link.rel = 'noopener noreferrer';
|
|
237
|
+
link.textContent = card.ctaLabel;
|
|
238
|
+
slide.append(copy);
|
|
239
|
+
|
|
240
|
+
if (card.image) {
|
|
241
|
+
const image = document.createElement('img');
|
|
242
|
+
image.className = 'hagilight-promoto__image';
|
|
243
|
+
image.src = card.image.src;
|
|
244
|
+
image.alt = card.image.alt || card.title;
|
|
245
|
+
image.loading = index === this.currentIndex ? 'eager' : 'lazy';
|
|
246
|
+
image.decoding = 'async';
|
|
247
|
+
if (card.image.width) image.width = card.image.width;
|
|
248
|
+
if (card.image.height) image.height = card.image.height;
|
|
249
|
+
slide.append(image);
|
|
250
|
+
}
|
|
251
|
+
slide.append(link);
|
|
252
|
+
fragment.append(slide);
|
|
253
|
+
});
|
|
254
|
+
track.replaceChildren(fragment);
|
|
255
|
+
this.updateSlides();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private updateSlides(): void {
|
|
259
|
+
const track = this.querySelector<HTMLElement>('[data-promoto-track]');
|
|
260
|
+
if (!track) return;
|
|
261
|
+
Array.from(track.children).forEach((slide, index) => {
|
|
262
|
+
const active = index === this.currentIndex;
|
|
263
|
+
slide.setAttribute('aria-hidden', String(!active));
|
|
264
|
+
if (active) slide.removeAttribute('inert');
|
|
265
|
+
else slide.setAttribute('inert', '');
|
|
266
|
+
});
|
|
267
|
+
track.style.transform = `translateX(-${this.currentIndex * 100}%)`;
|
|
268
|
+
const current = this.cards[this.currentIndex];
|
|
269
|
+
const status = this.querySelector<HTMLElement>('[data-promoto-status]');
|
|
270
|
+
if (status) status.textContent = current?.title ?? '';
|
|
271
|
+
const count = this.querySelector<HTMLElement>('[data-promoto-count]');
|
|
272
|
+
if (count) count.textContent = `${this.currentIndex + 1} / ${this.cards.length}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private updateControls(): void {
|
|
276
|
+
const multiple = this.cards.length > 1;
|
|
277
|
+
const controls = this.querySelector<HTMLElement>('[data-promoto-controls]');
|
|
278
|
+
if (controls) controls.hidden = !multiple;
|
|
279
|
+
const pause = this.querySelector<HTMLButtonElement>('[data-promoto-pause]');
|
|
280
|
+
if (pause) {
|
|
281
|
+
const paused = this.paused || this.reducedMotion;
|
|
282
|
+
pause.disabled = this.reducedMotion;
|
|
283
|
+
pause.setAttribute('aria-pressed', String(paused));
|
|
284
|
+
pause.setAttribute('aria-label', paused ? 'Resume automatic promotion rotation' : 'Pause automatic promotion rotation');
|
|
285
|
+
pause.textContent = paused ? 'Resume' : 'Pause';
|
|
286
|
+
}
|
|
287
|
+
this.updateSlides();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private move(direction: -1 | 1): void {
|
|
291
|
+
if (this.cards.length < 2) return;
|
|
292
|
+
this.currentIndex = getNextPromotionIndex(this.currentIndex, this.cards.length, direction);
|
|
293
|
+
this.updateSlides();
|
|
294
|
+
this.syncRotation();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private updateVisibility(): void {
|
|
298
|
+
const signature = getPromotionSetSignature(this.cards);
|
|
299
|
+
const state = getPromotionVisibility(
|
|
300
|
+
this.cards,
|
|
301
|
+
signature,
|
|
302
|
+
this.dismissedSignature,
|
|
303
|
+
this.isFooterVisible(),
|
|
304
|
+
);
|
|
305
|
+
const hidden = state === 'hidden' || state === 'dismissed';
|
|
306
|
+
const shell = this.querySelector<HTMLElement>('[data-promoto-shell]');
|
|
307
|
+
this.hidden = hidden;
|
|
308
|
+
if (shell) {
|
|
309
|
+
shell.hidden = hidden;
|
|
310
|
+
shell.inert = hidden || state === 'footer-hidden';
|
|
311
|
+
}
|
|
312
|
+
this.dataset.state = state;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private isFooterVisible(): boolean {
|
|
316
|
+
if (!this.footer) return false;
|
|
317
|
+
const bounds = this.footer.getBoundingClientRect();
|
|
318
|
+
return bounds.top < window.innerHeight && bounds.bottom > 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private syncRotation(): void {
|
|
322
|
+
this.stopRotation();
|
|
323
|
+
const state = this.dataset.state;
|
|
324
|
+
if (state !== 'ready' && state !== 'footer-hidden' && state !== 'hidden' && state !== 'dismissed') return;
|
|
325
|
+
if (!shouldAutoRotate(
|
|
326
|
+
this.cards.length,
|
|
327
|
+
this.paused,
|
|
328
|
+
this.reducedMotion,
|
|
329
|
+
document.visibilityState !== 'hidden',
|
|
330
|
+
state,
|
|
331
|
+
)) return;
|
|
332
|
+
this.rotationTimer = window.setInterval(() => this.move(1), ROTATION_INTERVAL_MS);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private stopRotation(): void {
|
|
336
|
+
if (this.rotationTimer !== undefined) window.clearInterval(this.rotationTimer);
|
|
337
|
+
this.rotationTimer = undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private startRefresh(): void {
|
|
341
|
+
this.stopRefresh();
|
|
342
|
+
if (document.visibilityState !== 'hidden') {
|
|
343
|
+
this.refreshTimer = window.setInterval(() => void this.reload(), REFRESH_INTERVAL_MS);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private stopRefresh(): void {
|
|
348
|
+
if (this.refreshTimer !== undefined) window.clearInterval(this.refreshTimer);
|
|
349
|
+
this.refreshTimer = undefined;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
customElements.define(ELEMENT_NAME, PromotoBannerElement);
|
|
354
|
+
}
|