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.
- package/CHANGELOG.md +65 -0
- package/README.md +45 -4
- package/index.d.ts +1 -0
- package/index.mjs +10 -3
- package/package.json +1 -1
- package/src/components/CardsHome.astro +113 -0
- package/src/components/ProductLanding.astro +445 -0
- package/src/components/ProductsIndex.astro +69 -0
- package/src/content.ts +150 -1
- package/src/i18n/ui.ts +8 -0
- package/src/layouts/BaseLayout.astro +66 -13
- package/src/lib/home.ts +63 -0
- package/src/lib/products.ts +31 -0
- package/src/lib/sections.ts +2 -0
- package/src/pages/[...lang]/index.astro +3 -1
- package/src/pages/[...lang]/products/[...id].astro +67 -0
- package/src/pages/[...lang]/products/index.astro +18 -0
|
@@ -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
|
}
|