stack-site-builder 1.15.0 → 1.17.0

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.
@@ -0,0 +1,445 @@
1
+ ---
2
+ // Things-style product landing, rendered entirely from a `products` entry's
3
+ // frontmatter (`template: 'landing'`): hero (icon / store buttons / Product
4
+ // Hunt badge), alternating feature rows (with optional device frame and
5
+ // auto-rotating screenshot carousels), a video "themes" showcase, a highlights
6
+ // grid, pricing tiers, a closing CTA and legal links. Sections render only
7
+ // when their data is present, so a minimal landing is just hero + features.
8
+ //
9
+ // All media (icons, screenshots, videos, the frame art) are `public/` paths;
10
+ // `description`-like strings may carry authored <br> tags (rendered as HTML).
11
+ import { site } from '@aas-data/site';
12
+ import type { ProductEntry } from '../lib/products';
13
+ import type { Lang } from '../i18n/ui';
14
+
15
+ interface Props {
16
+ entry: ProductEntry;
17
+ lang: Lang;
18
+ }
19
+
20
+ const { entry } = Astro.props;
21
+ const d = entry.data;
22
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
23
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
24
+ const frame = withBase(d.phoneFrameImage);
25
+ // Optional site contact for the legal "support" link (read defensively — not
26
+ // every site declares an email).
27
+ const email = (site as { email?: string }).email;
28
+
29
+ const storeButtons = d.stores
30
+ ? [
31
+ { href: d.stores.appstore, brand: 'App Store', label: d.stores.appstoreLabel },
32
+ { href: d.stores.playstore, brand: 'Google Play', label: d.stores.playstoreLabel },
33
+ ].filter((b) => b.href)
34
+ : [];
35
+
36
+ // Alternation matches the Hugo original: odd rows get the tinted background,
37
+ // even rows put the visual on the left.
38
+ const altBg = (i: number) => i % 2 === 1;
39
+ ---
40
+
41
+ <div class="aas-landing">
42
+ {/* ---- hero ---- */}
43
+ <section
44
+ class:list={[
45
+ 'aas-bleed py-16 text-center',
46
+ d.heroBg && `aas-hero-bg-${d.heroBg}`,
47
+ ]}
48
+ >
49
+ <div class="mx-auto max-w-[75rem] px-5">
50
+ {d.icon && <img src={withBase(d.icon)} alt="" class="mx-auto h-24 w-24 rounded-[22%] shadow-lg" />}
51
+ <h1 class="mt-6 text-4xl font-bold tracking-tight sm:text-5xl">{d.title}</h1>
52
+ {d.subtitle && <p class="mt-3 text-xl text-[var(--aas-muted)]" set:html={d.subtitle} />}
53
+ {
54
+ storeButtons.length > 0 && (
55
+ <div class="mt-7 flex flex-wrap items-start justify-center gap-3">
56
+ {storeButtons.map((b) => (
57
+ <span class="inline-flex flex-col items-center gap-1.5">
58
+ <a
59
+ href={b.href === '#' ? undefined : b.href}
60
+ {...(b.href !== '#' ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
61
+ class:list={[
62
+ 'rounded-full bg-[var(--aas-accent)] px-6 py-2.5 font-semibold text-white no-underline',
63
+ b.href === '#' && 'pointer-events-none opacity-40',
64
+ ]}
65
+ >
66
+ {b.brand}
67
+ </a>
68
+ {b.label && <span class="text-xs text-[var(--aas-muted)]">{b.label}</span>}
69
+ </span>
70
+ ))}
71
+ </div>
72
+ )
73
+ }
74
+ {d.tagline && <p class="mt-5 text-sm text-[var(--aas-muted)]" set:html={d.tagline} />}
75
+ {
76
+ d.productHunt && (
77
+ <p class="mt-6">
78
+ <a href={d.productHunt.url} target="_blank" rel="noopener noreferrer" class="inline-block">
79
+ <img src={d.productHunt.image} alt={d.productHunt.alt} width="250" height="54" loading="lazy" />
80
+ </a>
81
+ </p>
82
+ )
83
+ }
84
+ </div>
85
+ </section>
86
+
87
+ {/* ---- alternating feature rows ---- */}
88
+ {
89
+ d.features.map((f, i) => {
90
+ const imgs = f.images.length > 0 ? f.images : f.image ? [f.image] : [];
91
+ const isSvg = imgs.length === 1 && /\.svg$/i.test(imgs[0]);
92
+ return (
93
+ <section class:list={['aas-bleed py-16', altBg(i) && 'bg-[var(--aas-panel)]']}>
94
+ <div
95
+ class:list={[
96
+ 'mx-auto grid max-w-[75rem] items-center gap-10 px-5 md:grid-cols-2',
97
+ ]}
98
+ >
99
+ <div class:list={[!altBg(i) && 'md:order-2']}>
100
+ {f.label && (
101
+ <p class="text-sm font-semibold tracking-wide text-[var(--aas-accent)] uppercase">{f.label}</p>
102
+ )}
103
+ <h2 class="mt-2 text-3xl font-bold tracking-tight">{f.title}</h2>
104
+ <p class="mt-4 leading-relaxed text-[var(--aas-muted)]" set:html={f.description} />
105
+ </div>
106
+ <div class:list={['flex justify-center', !altBg(i) && 'md:order-1']}>
107
+ {imgs.length > 1 ? (
108
+ <div class="aas-carousel w-full max-w-xs" data-carousel>
109
+ <div class="aas-carousel-track" data-track>
110
+ {imgs.map((src) => (
111
+ <div class="aas-carousel-slide">
112
+ {f.phoneFrame && frame ? (
113
+ <span class="aas-phone">
114
+ <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />
115
+ <img class="aas-phone-screen" src={withBase(src)} alt={f.title} loading="lazy" />
116
+ </span>
117
+ ) : (
118
+ <img src={withBase(src)} alt={f.title} class="w-full rounded-2xl" loading="lazy" />
119
+ )}
120
+ </div>
121
+ ))}
122
+ </div>
123
+ <div class="mt-3 flex justify-center gap-1.5" data-dots />
124
+ </div>
125
+ ) : imgs.length === 1 ? (
126
+ isSvg ? (
127
+ <img src={withBase(imgs[0])} alt={f.title} class="aas-feature-icon" loading="lazy" />
128
+ ) : f.phoneFrame && frame ? (
129
+ <span class="aas-phone max-w-xs">
130
+ <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />
131
+ <img class="aas-phone-screen" src={withBase(imgs[0])} alt={f.title} loading="lazy" />
132
+ </span>
133
+ ) : (
134
+ <img src={withBase(imgs[0])} alt={f.title} class="max-w-full rounded-2xl" loading="lazy" />
135
+ )
136
+ ) : null}
137
+ </div>
138
+ </div>
139
+ </section>
140
+ );
141
+ })
142
+ }
143
+
144
+ {/* ---- themes showcase (video tabs) ---- */}
145
+ {
146
+ d.themes.length > 0 && (
147
+ <section class:list={['aas-bleed py-16 text-center', altBg(d.features.length) && 'bg-[var(--aas-panel)]']}>
148
+ <div class="mx-auto max-w-[75rem] px-5" data-themes>
149
+ {d.themesTitle && <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{d.themesTitle}</h2>}
150
+ {d.themesSubtitle && <p class="mt-2 mb-10 text-[var(--aas-muted)]">{d.themesSubtitle}</p>}
151
+ <div class="mb-8 flex justify-center gap-2">
152
+ {d.themes.map((t, i) => (
153
+ <button
154
+ type="button"
155
+ class="aas-theme-tab rounded-full border border-[var(--aas-border)] px-4 py-2 text-lg"
156
+ data-theme={String(i)}
157
+ aria-pressed={i === 0 ? 'true' : 'false'}
158
+ title={t.name}
159
+ >
160
+ {t.icon ?? String(i + 1)}
161
+ </button>
162
+ ))}
163
+ </div>
164
+ <div class="grid items-center gap-10 md:grid-cols-2">
165
+ <div class="flex justify-center">
166
+ <span class="aas-phone max-w-xs">
167
+ {frame && <img class="aas-phone-frame" src={frame} alt="" loading="lazy" />}
168
+ {d.themes.map((t, i) => (
169
+ <video
170
+ class:list={['aas-phone-screen aas-theme-video', i !== 0 && 'hidden']}
171
+ data-theme={String(i)}
172
+ autoplay={i === 0}
173
+ muted
174
+ playsinline
175
+ poster={withBase(t.poster)}
176
+ >
177
+ <source src={withBase(t.video)} type="video/mp4" />
178
+ </video>
179
+ ))}
180
+ </span>
181
+ </div>
182
+ <div class="text-left">
183
+ {d.themes.map((t, i) => (
184
+ <div class:list={['aas-theme-detail', i !== 0 && 'hidden']} data-theme={String(i)}>
185
+ <h3 class="text-2xl font-bold">{t.name}</h3>
186
+ {t.tabDesc && <p class="mt-1 text-sm text-[var(--aas-accent)]">{t.tabDesc}</p>}
187
+ <p class="mt-4 leading-relaxed text-[var(--aas-muted)]" set:html={t.description} />
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ {d.themesLandscape && (
193
+ <div class="mt-14">
194
+ <h3 class="text-xl font-bold">{d.themesLandscape.title}</h3>
195
+ <p class="mt-1 mb-6 text-[var(--aas-muted)]">{d.themesLandscape.description}</p>
196
+ <img
197
+ src={withBase(d.themesLandscape.image)}
198
+ alt={d.themesLandscape.title}
199
+ class="mx-auto max-w-full rounded-2xl sm:max-w-2xl"
200
+ loading="lazy"
201
+ />
202
+ </div>
203
+ )}
204
+ </div>
205
+ </section>
206
+ )
207
+ }
208
+
209
+ {/* ---- highlights grid ---- */}
210
+ {
211
+ d.highlights.length > 0 && (
212
+ <section class="aas-bleed bg-[var(--aas-panel)] py-16 text-center">
213
+ <div class="mx-auto max-w-[75rem] px-5">
214
+ {d.highlightsTitle && <h2 class="mb-10 text-3xl font-bold tracking-tight sm:text-4xl">{d.highlightsTitle}</h2>}
215
+ <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
216
+ {d.highlights.map((h) => (
217
+ <div class="rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-bg)] p-6">
218
+ {h.icon && <img src={withBase(h.icon)} alt="" class="aas-feature-glyph mx-auto mb-4 h-10 w-10" loading="lazy" />}
219
+ <h3 class="font-semibold">{h.title}</h3>
220
+ <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]" set:html={h.description} />
221
+ </div>
222
+ ))}
223
+ </div>
224
+ </div>
225
+ </section>
226
+ )
227
+ }
228
+
229
+ {/* ---- pricing ---- */}
230
+ {
231
+ d.pricing.length > 0 && (
232
+ <section class="aas-bleed py-16 text-center">
233
+ <div class="mx-auto max-w-[75rem] px-5">
234
+ {d.pricingTitle && <h2 class="text-3xl font-bold tracking-tight sm:text-4xl">{d.pricingTitle}</h2>}
235
+ {d.pricingSubtitle && <p class="mt-2 text-[var(--aas-muted)]">{d.pricingSubtitle}</p>}
236
+ {d.pricingNotes.length > 0 && (
237
+ <p class="mt-2 text-sm text-[var(--aas-muted)] opacity-75">
238
+ {d.pricingNotes.map((n, i) => (
239
+ <>
240
+ {i > 0 && <br />}
241
+ {n}
242
+ </>
243
+ ))}
244
+ </p>
245
+ )}
246
+ <div class="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
247
+ {d.pricing.map((p) => (
248
+ <div
249
+ class:list={[
250
+ 'relative rounded-3xl border p-6 text-left',
251
+ p.featured
252
+ ? 'border-[var(--aas-accent)] bg-[var(--aas-panel)] shadow-lg'
253
+ : 'border-[var(--aas-border)] bg-[var(--aas-panel)]',
254
+ ]}
255
+ >
256
+ {p.featured && d.pricingBadge && (
257
+ <span class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-[var(--aas-accent)] px-3 py-0.5 text-xs font-semibold text-white">
258
+ {d.pricingBadge}
259
+ </span>
260
+ )}
261
+ <h3 class="font-semibold">{p.name}</h3>
262
+ <p class="mt-2 text-3xl font-bold">
263
+ {p.price}
264
+ {p.period && <span class="ml-1 text-sm font-normal text-[var(--aas-muted)]">{p.period}</span>}
265
+ </p>
266
+ <ul class="mt-4 space-y-2 text-sm text-[var(--aas-muted)]">
267
+ {p.items.map((it) => (
268
+ <li class="flex gap-2">
269
+ <span aria-hidden="true" class="text-[var(--aas-accent)]">✓</span>
270
+ {it}
271
+ </li>
272
+ ))}
273
+ </ul>
274
+ </div>
275
+ ))}
276
+ </div>
277
+ </div>
278
+ </section>
279
+ )
280
+ }
281
+
282
+ {/* ---- closing CTA ---- */}
283
+ {
284
+ (d.ctaTitle || d.ctaDescription) && (
285
+ <section class="aas-bleed bg-[var(--aas-panel)] py-16 text-center">
286
+ <div class="mx-auto max-w-[75rem] px-5">
287
+ {d.ctaTitle && <h2 class="text-3xl font-bold tracking-tight">{d.ctaTitle}</h2>}
288
+ {d.ctaDescription && <p class="mt-3 text-[var(--aas-muted)]" set:html={d.ctaDescription} />}
289
+ {storeButtons.length > 0 && (
290
+ <div class="mt-7 flex flex-wrap items-start justify-center gap-3">
291
+ {storeButtons.map((b) => (
292
+ <span class="inline-flex flex-col items-center gap-1.5">
293
+ <a
294
+ href={b.href === '#' ? undefined : b.href}
295
+ {...(b.href !== '#' ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
296
+ class:list={[
297
+ 'rounded-full bg-[var(--aas-accent)] px-6 py-2.5 font-semibold text-white no-underline',
298
+ b.href === '#' && 'pointer-events-none opacity-40',
299
+ ]}
300
+ >
301
+ {b.brand}
302
+ </a>
303
+ {b.label && <span class="text-xs text-[var(--aas-muted)]">{b.label}</span>}
304
+ </span>
305
+ ))}
306
+ </div>
307
+ )}
308
+ </div>
309
+ </section>
310
+ )
311
+ }
312
+
313
+ {/* ---- legal links ---- */}
314
+ {
315
+ d.legal && (
316
+ <section class="py-10 text-center text-sm">
317
+ <div class="flex flex-wrap justify-center gap-6 text-[var(--aas-muted)]">
318
+ {d.legal.privacy && <a href="privacy/" class="no-underline hover:text-[var(--aas-text)]">{d.legal.privacy}</a>}
319
+ {d.legal.terms && <a href="terms/" class="no-underline hover:text-[var(--aas-text)]">{d.legal.terms}</a>}
320
+ {d.legal.support && email && (
321
+ <a href={`mailto:${email}`} class="no-underline hover:text-[var(--aas-text)]">{d.legal.support}</a>
322
+ )}
323
+ </div>
324
+ </section>
325
+ )
326
+ }
327
+ </div>
328
+
329
+ <style>
330
+ /* Full-bleed section: escape BaseLayout's centered container. */
331
+ .aas-bleed {
332
+ position: relative;
333
+ left: 50%;
334
+ margin-left: -50vw;
335
+ margin-right: -50vw;
336
+ right: 50%;
337
+ width: 100vw;
338
+ }
339
+ /* Screenshot inside the device-frame art. The frame draws on top; the
340
+ screen sits inset behind it (tune per frame art via site CSS if needed). */
341
+ .aas-phone {
342
+ position: relative;
343
+ display: inline-block;
344
+ }
345
+ .aas-phone-frame {
346
+ position: relative;
347
+ z-index: 2;
348
+ width: 100%;
349
+ pointer-events: none;
350
+ }
351
+ .aas-phone :global(.aas-phone-screen) {
352
+ position: absolute;
353
+ z-index: 1;
354
+ inset: 2.5% 5.5%;
355
+ width: 89%;
356
+ height: 95%;
357
+ object-fit: cover;
358
+ border-radius: 9% / 4.5%;
359
+ }
360
+ .aas-feature-icon {
361
+ width: 10rem;
362
+ opacity: 0.9;
363
+ }
364
+ /* Monochrome icon files follow the text color in dark mode. */
365
+ :root[data-theme='dark'] .aas-feature-glyph,
366
+ :root[data-theme='dark'] .aas-feature-icon {
367
+ filter: invert(1) hue-rotate(180deg);
368
+ }
369
+ .aas-carousel {
370
+ overflow: hidden;
371
+ }
372
+ .aas-carousel-track {
373
+ display: flex;
374
+ transition: transform 0.45s ease;
375
+ }
376
+ .aas-carousel-slide {
377
+ flex: 0 0 100%;
378
+ display: flex;
379
+ justify-content: center;
380
+ }
381
+ .aas-theme-tab[aria-pressed='true'] {
382
+ border-color: var(--aas-accent);
383
+ background: var(--aas-tint);
384
+ }
385
+ </style>
386
+
387
+ <script>
388
+ // Screenshot carousels: dots + 4s auto-advance.
389
+ document.querySelectorAll<HTMLElement>('[data-carousel]').forEach((carousel) => {
390
+ const track = carousel.querySelector<HTMLElement>('[data-track]');
391
+ const dots = carousel.querySelector<HTMLElement>('[data-dots]');
392
+ const total = track?.children.length ?? 0;
393
+ if (!track || !dots || total < 2) return;
394
+ let current = 0;
395
+ const buttons: HTMLButtonElement[] = [];
396
+ const goTo = (idx: number) => {
397
+ current = idx;
398
+ track.style.transform = `translateX(-${current * 100}%)`;
399
+ buttons.forEach((b, j) => b.setAttribute('aria-pressed', String(j === current)));
400
+ };
401
+ for (let i = 0; i < total; i++) {
402
+ const b = document.createElement('button');
403
+ b.type = 'button';
404
+ b.className =
405
+ 'h-2 w-2 rounded-full border border-[var(--aas-border)] aria-[pressed=true]:bg-[var(--aas-accent)] aria-[pressed=true]:border-[var(--aas-accent)]';
406
+ b.setAttribute('aria-pressed', String(i === 0));
407
+ b.setAttribute('aria-label', String(i + 1));
408
+ b.addEventListener('click', () => goTo(i));
409
+ dots.appendChild(b);
410
+ buttons.push(b);
411
+ }
412
+ setInterval(() => goTo((current + 1) % total), 4000);
413
+ });
414
+
415
+ // Themes showcase: tab switching; a finished video advances to the next
416
+ // theme after a beat.
417
+ document.querySelectorAll<HTMLElement>('[data-themes]').forEach((root) => {
418
+ const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('.aas-theme-tab'));
419
+ const videos = Array.from(root.querySelectorAll<HTMLVideoElement>('.aas-theme-video'));
420
+ const details = Array.from(root.querySelectorAll<HTMLElement>('.aas-theme-detail'));
421
+ if (tabs.length < 2) return;
422
+ const switchTo = (idx: number) => {
423
+ const s = String(idx);
424
+ tabs.forEach((t) => t.setAttribute('aria-pressed', String(t.dataset.theme === s)));
425
+ details.forEach((el) => el.classList.toggle('hidden', el.dataset.theme !== s));
426
+ videos.forEach((v) => {
427
+ const on = v.dataset.theme === s;
428
+ v.classList.toggle('hidden', !on);
429
+ if (on) {
430
+ v.currentTime = 0;
431
+ v.play().catch(() => {});
432
+ } else {
433
+ v.pause();
434
+ }
435
+ });
436
+ };
437
+ tabs.forEach((t) => t.addEventListener('click', () => switchTo(Number(t.dataset.theme))));
438
+ videos.forEach((v) =>
439
+ v.addEventListener('ended', () => {
440
+ const cur = Number(v.dataset.theme);
441
+ setTimeout(() => switchTo((cur + 1) % tabs.length), 2000);
442
+ }),
443
+ );
444
+ });
445
+ </script>
@@ -0,0 +1,69 @@
1
+ ---
2
+ // The products index — the "what we offer" page. Top-level products (app
3
+ // landings, service pitches, education offers) render as wide cards (icon,
4
+ // name, subtitle, meta description) grouped by the site's mini-taxonomy
5
+ // (src/data/product-categories.ts). Cards mirror the cards-home style so the
6
+ // two read as one system.
7
+ import { getRelativeLocaleUrl } from 'astro:i18n';
8
+ import { productTree, productCatOf } from '@aas-data/product-categories';
9
+ import { getTopProducts, productSlugOf, type ProductEntry } from '../lib/products';
10
+ import { useTranslations, type Lang } from '../i18n/ui';
11
+
12
+ interface Props {
13
+ lang: Lang;
14
+ }
15
+
16
+ const { lang } = Astro.props;
17
+ const t = useTranslations(lang);
18
+ const all = await getTopProducts(lang);
19
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
20
+ const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
21
+
22
+ // One section per root category that has products (subtree rolled up), in
23
+ // tree order — the mini-taxonomy is the page structure.
24
+ const sections = productTree.roots
25
+ .map((cat) => {
26
+ const ids = new Set(productTree.descendantIds(cat.id));
27
+ return { cat, items: all.filter((p) => ids.has(productCatOf(p.data.category))) };
28
+ })
29
+ .filter((s) => s.items.length > 0);
30
+ ---
31
+
32
+ <section class="py-6">
33
+ <h1 class="text-4xl font-bold tracking-tight">{t('products.title')}</h1>
34
+ <p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('products.tagline')}</p>
35
+ </section>
36
+
37
+ {all.length === 0 && <p class="mt-8 text-[var(--aas-muted)]">{t('products.empty')}</p>}
38
+
39
+ {
40
+ sections.map(({ cat, items }) => (
41
+ <section class="mt-8">
42
+ <h2 class="text-2xl font-semibold">{cat.label[lang]}</h2>
43
+ <p class="mt-1 text-sm text-[var(--aas-muted)]">{cat.description[lang]}</p>
44
+ <div class="mt-4 flex flex-col gap-5">
45
+ {items.map((a: ProductEntry) => (
46
+ <a
47
+ href={getRelativeLocaleUrl(lang, `products/${productSlugOf(a)}/`)}
48
+ class="aas-lift flex items-center gap-6 rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-6 no-underline"
49
+ >
50
+ {a.data.icon && (
51
+ <img src={withBase(a.data.icon)} alt="" class="h-20 w-20 shrink-0 rounded-[22%]" loading="lazy" />
52
+ )}
53
+ <span class="min-w-0">
54
+ <span class="block text-xl font-semibold text-[var(--aas-text)]">{a.data.title}</span>
55
+ {a.data.subtitle && (
56
+ <span class="mt-1 block leading-relaxed text-[var(--aas-muted)]" set:html={a.data.subtitle} />
57
+ )}
58
+ {a.data.description && (
59
+ <span class="mt-1 block text-sm leading-relaxed text-[var(--aas-muted)] opacity-75">
60
+ {a.data.description}
61
+ </span>
62
+ )}
63
+ </span>
64
+ </a>
65
+ ))}
66
+ </div>
67
+ </section>
68
+ ))
69
+ }
package/src/content.ts CHANGED
@@ -18,12 +18,16 @@ import { glob } from 'astro/loaders';
18
18
  export function defineAasCollections({
19
19
  categoryMap,
20
20
  courseCategoryMap,
21
+ productCategoryMap,
21
22
  }: {
22
23
  categoryMap: Map<string, unknown>;
23
24
  /** The site's course category tree (`src/data/course-categories.ts`). Optional
24
25
  * because `courses` is an opt-in section; when provided, course `category`
25
26
  * ids are validated against it at build time (like stacks). */
26
27
  courseCategoryMap?: Map<string, unknown>;
28
+ /** The site's product category tree (`src/data/product-categories.ts`).
29
+ * Optional like `courseCategoryMap` — `products` is opt-in too. */
30
+ productCategoryMap?: Map<string, unknown>;
27
31
  }) {
28
32
  /**
29
33
  * The `stacks` collection holds one entry per tool/service used to build
@@ -306,6 +310,151 @@ export function defineAasCollections({
306
310
  }),
307
311
  });
308
312
 
313
+ /**
314
+ * The `products` collection is the "what we offer" section — an opt-in
315
+ * umbrella (`sections: { products: true }`) whose entries are grouped by a
316
+ * mini-taxonomy (apps, services, education, …) on the `/products/` index.
317
+ * Each entry is either a Things-style marketing landing
318
+ * (`template: 'landing'`) or a plain prose page (`template: 'page'`, the
319
+ * default — an outsourcing pitch, a legal subpage). Entries are
320
+ * locale-partitioned and may nest: `products/<lang>/<slug>.mdx` renders at
321
+ * `/products/<slug>/`, `products/<lang>/<slug>/privacy.mdx` at
322
+ * `/products/<slug>/privacy/`.
323
+ *
324
+ * Landing visuals (icons, screenshots, videos, the phone-frame art) are
325
+ * plain `public/` paths — videos can't go through the image pipeline, so
326
+ * the whole landing keeps one convention instead of two.
327
+ */
328
+ const products = defineCollection({
329
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/products' }),
330
+ schema: z.object({
331
+ title: z.string(),
332
+ description: z.string().optional(), // meta description
333
+ // 'landing' renders the structured marketing page below; 'page' renders
334
+ // the markdown body like a standalone page (pitches, legal subpages).
335
+ template: z.enum(['landing', 'page']).default('page'),
336
+ // Mini-category on the site's src/data/product-categories.ts tree —
337
+ // groups the index. Validated when the site passes `productCategoryMap`;
338
+ // unknown/missing ids fall back to uncategorized at render.
339
+ category: (productCategoryMap
340
+ ? z.string().refine((id) => productCategoryMap.has(id), {
341
+ message:
342
+ 'unknown product category id — must match a node in the site data product category tree',
343
+ })
344
+ : z.string()
345
+ ).optional(),
346
+ // Header-nav placement, like the `pages` collection (default off —
347
+ // products are usually reached from the index or the home cards).
348
+ nav: z.boolean().default(false),
349
+ navLabel: z.string().optional(),
350
+ order: z.number().default(0),
351
+
352
+ // ---- hero ----
353
+ subtitle: z.string().optional(),
354
+ icon: z.string().optional(), // app icon (public/ path)
355
+ // Free-form key a site can target from its CSS (`.aas-hero-bg-<key>`)
356
+ // for a custom hero background.
357
+ heroBg: z.string().optional(),
358
+ tagline: z.string().optional(), // small print under the store buttons (may contain <br>)
359
+ productHunt: z
360
+ .object({ url: z.string().url(), image: z.string().url(), alt: z.string() })
361
+ .optional(),
362
+ // Store links; "#" renders the button disabled (not yet released), the
363
+ // label is small print under a button (e.g. "5월 출시 예정").
364
+ stores: z
365
+ .object({
366
+ appstore: z.string().optional(),
367
+ appstoreLabel: z.string().optional(),
368
+ playstore: z.string().optional(),
369
+ playstoreLabel: z.string().optional(),
370
+ })
371
+ .optional(),
372
+
373
+ // ---- alternating feature rows ----
374
+ // Device-frame art that screenshots render inside when `phoneFrame` is
375
+ // set (one per page; features opt in individually).
376
+ phoneFrameImage: z.string().optional(),
377
+ features: z
378
+ .array(
379
+ z.object({
380
+ label: z.string().optional(), // small eyebrow above the title
381
+ title: z.string(),
382
+ description: z.string(), // may contain <br>
383
+ image: z.string().optional(),
384
+ images: z.array(z.string()).default([]), // 2+ → auto-rotating carousel
385
+ phoneFrame: z.boolean().default(false),
386
+ }),
387
+ )
388
+ .default([]),
389
+
390
+ // ---- highlights grid (icon cards) ----
391
+ highlightsTitle: z.string().optional(),
392
+ highlights: z
393
+ .array(
394
+ z.object({
395
+ icon: z.string().optional(), // public/ path
396
+ title: z.string(),
397
+ description: z.string(),
398
+ }),
399
+ )
400
+ .default([]),
401
+
402
+ // ---- themes showcase (video tabs, auto-rotating) ----
403
+ themesTitle: z.string().optional(),
404
+ themesSubtitle: z.string().optional(),
405
+ themes: z
406
+ .array(
407
+ z.object({
408
+ name: z.string(),
409
+ description: z.string(),
410
+ tabDesc: z.string().optional(),
411
+ video: z.string(), // public/ path (mp4)
412
+ poster: z.string().optional(),
413
+ icon: z.string().optional(), // emoji or short text on the tab button
414
+ }),
415
+ )
416
+ .default([]),
417
+ themesLandscape: z
418
+ .object({ title: z.string(), description: z.string(), image: z.string() })
419
+ .optional(),
420
+
421
+ // ---- pricing ----
422
+ pricingTitle: z.string().optional(),
423
+ pricingSubtitle: z.string().optional(),
424
+ pricingBadge: z.string().optional(), // ribbon on the featured tier
425
+ pricingNotes: z.array(z.string()).default([]),
426
+ pricing: z
427
+ .array(
428
+ z.object({
429
+ name: z.string(),
430
+ price: z.string(),
431
+ period: z.string().optional(), // "/월", "일회성", …
432
+ featured: z.boolean().default(false),
433
+ items: z.array(z.string()).default([]),
434
+ }),
435
+ )
436
+ .default([]),
437
+
438
+ // ---- closing CTA + legal links ----
439
+ ctaTitle: z.string().optional(),
440
+ ctaDescription: z.string().optional(),
441
+ // Localized labels; privacy/terms link to the sibling subpages, support
442
+ // opens mailto site.email.
443
+ legal: z
444
+ .object({
445
+ privacy: z.string().optional(),
446
+ terms: z.string().optional(),
447
+ support: z.string().optional(),
448
+ })
449
+ .optional(),
450
+
451
+ draft: z.boolean().default(false),
452
+ // Login-gated page (encrypted body; see docs/private-content-design.md).
453
+ private: z.boolean().default(false),
454
+ teaser: z.string().optional(),
455
+ }),
456
+ });
457
+
309
458
  /**
310
459
  * The `pages` collection holds standalone top-level pages — an About/소개, a
311
460
  * contact page, terms, etc. Unlike the other collections there's no index or
@@ -338,5 +487,5 @@ export function defineAasCollections({
338
487
  }),
339
488
  });
340
489
 
341
- return { stacks, articles, concepts, courses, slides, pages };
490
+ return { stacks, articles, concepts, courses, slides, products, pages };
342
491
  }