@astryxdesign/cli 0.7.11 → 0.7.12-canary.ccb4a6d

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.
Files changed (68) hide show
  1. package/api/build/kit/rank.mjs +21 -4
  2. package/api/build/kit/words.d.mts +1 -0
  3. package/api/build/kit/words.mjs +1 -1
  4. package/api/icon/_adapter.d.mts +3 -2
  5. package/api/icon/_adapter.mjs +8 -2
  6. package/api/icon/_authoring.d.mts +1 -0
  7. package/api/icon/_authoring.mjs +586 -66
  8. package/api/icon/_default-look.d.mts +155 -0
  9. package/api/icon/_default-look.mjs +474 -0
  10. package/api/icon/_records.d.mts +1 -1
  11. package/api/icon/_records.mjs +1 -0
  12. package/api/icon/add/add.mjs +224 -38
  13. package/api/icon/icon.type.d.mts +22 -2
  14. package/api/icon/icon.type.mjs +6 -2
  15. package/api/icon/iconAdd.doc.mjs +2 -2
  16. package/api/icon/iconNewDefinition.doc.mjs +3 -2
  17. package/api/icon/iconNewSet.doc.mjs +4 -4
  18. package/api/icon/search/search.mjs +10 -9
  19. package/api/integration/add-helpers.d.mts +11 -1
  20. package/api/integration/add-helpers.mjs +5 -2
  21. package/api/integration/authoring-checks.d.mts +0 -2
  22. package/api/integration/authoring-checks.mjs +1 -6
  23. package/api/logger.mjs +2 -1
  24. package/api/swizzle/_adapter.d.mts +1 -0
  25. package/api/swizzle/_adapter.mjs +4 -1
  26. package/api/swizzle/_moduleGraph.d.mts +171 -0
  27. package/api/swizzle/_moduleGraph.mjs +795 -0
  28. package/api/swizzle/copy/copy.d.mts +2 -43
  29. package/api/swizzle/copy/copy.mjs +195 -314
  30. package/api/swizzle/swizzle.d.mts +0 -2
  31. package/api/swizzle/swizzle.doc.mjs +9 -4
  32. package/api/swizzle/swizzle.mjs +3 -7
  33. package/api/swizzle/swizzle.type.d.mts +2 -1
  34. package/api/swizzle/swizzle.type.mjs +3 -2
  35. package/assets/discover/official-packages.json +20 -0
  36. package/assets/docs/tree/icon-sets.doc.mjs +11 -5
  37. package/assets/templates/blocks/components/Table/TableRowIndexTable.doc.mjs +1 -1
  38. package/assets/templates/blocks/components/Table/TableRowIndexTable.tsx +2 -2
  39. package/assets/templates/pages/course-detail/page.tsx +699 -0
  40. package/assets/templates/pages/course-detail/template.doc.mjs +37 -0
  41. package/assets/templates/pages/file-explorer/page.tsx +103 -79
  42. package/assets/templates/pages/table-grouped/page.tsx +160 -232
  43. package/assets/templates/pages/table-grouped/template.doc.mjs +0 -2
  44. package/assets/templates/pages/theme-showcase/page.tsx +724 -792
  45. package/assets/templates/pages/theme-showcase/template.doc.mjs +4 -5
  46. package/authoring/doctypes/template/type.ts +1 -0
  47. package/clients/cli/commands/experimental/icon.mjs +31 -10
  48. package/clients/cli/commands/experimental-icon-add.doc.mjs +1 -1
  49. package/clients/cli/commands/experimental-icon-new-definition.doc.mjs +2 -1
  50. package/clients/cli/commands/experimental-icon-new-set.doc.mjs +2 -2
  51. package/clients/cli/commands/swizzle.doc.mjs +8 -3
  52. package/clients/cli/commands/swizzle.mjs +21 -9
  53. package/foundation/discovery/component-loader.d.mts +1 -1
  54. package/foundation/discovery/doc-types.mjs +9 -2
  55. package/foundation/discovery/icon-ranking.d.mts +27 -3
  56. package/foundation/discovery/icon-ranking.mjs +125 -8
  57. package/foundation/discovery/icon-set-checks.mjs +44 -35
  58. package/foundation/discovery/icon-set-discovery.d.mts +13 -0
  59. package/foundation/discovery/icon-set-discovery.mjs +25 -0
  60. package/foundation/discovery/icon-set-writer.d.mts +238 -16
  61. package/foundation/discovery/icon-set-writer.mjs +676 -79
  62. package/foundation/discovery/module-exports.d.mts +3 -2
  63. package/foundation/discovery/module-exports.mjs +167 -37
  64. package/foundation/fs/paths.d.mts +2 -1
  65. package/foundation/fs/paths.mjs +3 -4
  66. package/foundation/text/shell-arg.d.mts +14 -0
  67. package/foundation/text/shell-arg.mjs +17 -0
  68. package/package.json +6 -6
@@ -0,0 +1,699 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {
7
+ Layout,
8
+ LayoutContent,
9
+ Section,
10
+ VStack,
11
+ HStack,
12
+ StackItem,
13
+ } from '@astryxdesign/core/Layout';
14
+ import {Grid} from '@astryxdesign/core/Grid';
15
+ import {Text, Heading} from '@astryxdesign/core/Text';
16
+ import {Card} from '@astryxdesign/core/Card';
17
+ import {Button} from '@astryxdesign/core/Button';
18
+ import {Icon} from '@astryxdesign/core/Icon';
19
+ import {Badge} from '@astryxdesign/core/Badge';
20
+ import {Divider} from '@astryxdesign/core/Divider';
21
+ import {Avatar} from '@astryxdesign/core/Avatar';
22
+ import {Link} from '@astryxdesign/core/Link';
23
+ import {AspectRatio} from '@astryxdesign/core/AspectRatio';
24
+ import {Slider} from '@astryxdesign/core/Slider';
25
+ import {ProgressBar} from '@astryxdesign/core/ProgressBar';
26
+ import {Collapsible, CollapsibleGroup} from '@astryxdesign/core/Collapsible';
27
+ import {
28
+ SegmentedControl,
29
+ SegmentedControlItem,
30
+ } from '@astryxdesign/core/SegmentedControl';
31
+ import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList';
32
+ import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
33
+ import {rtlStyles} from '@astryxdesign/core/utils';
34
+ import type {CSSProperties, ReactNode} from 'react';
35
+
36
+ // Custom CSS here is limited to what Astryx components can't express today:
37
+ // - image fill + corner radius (no Image primitive — #2582)
38
+ // - the sticky enroll rail (no sticky prop on Astryx layout primitives — #2613)
39
+ const stickyRail: CSSProperties = {
40
+ position: 'sticky',
41
+ top: 'var(--spacing-8)',
42
+ alignSelf: 'start',
43
+ };
44
+ const posterImage: CSSProperties = {
45
+ width: '100%',
46
+ height: '100%',
47
+ objectFit: 'cover',
48
+ borderRadius: 'var(--radius-container)',
49
+ };
50
+
51
+ import {
52
+ PlayIcon,
53
+ CheckIcon,
54
+ StarIcon,
55
+ ClockIcon,
56
+ UsersIcon,
57
+ GlobeAltIcon,
58
+ BoltIcon,
59
+ ArrowRightIcon,
60
+ BookOpenIcon,
61
+ } from '@heroicons/react/24/outline';
62
+ import {StarIcon as StarIconSolid} from '@heroicons/react/24/solid';
63
+
64
+ // ─── Data ────────────────────────────────────────────────────────────────────
65
+ const COURSE = {
66
+ title: 'Applied AI: Build with Language Models',
67
+ level: 'Intermediate',
68
+ language: 'English',
69
+ rating: 4.8,
70
+ ratingCount: 1284,
71
+ learnerCount: '12,840',
72
+ lessonCount: 13,
73
+ runtime: '4h 07m',
74
+ price: 89,
75
+ compareAt: 129,
76
+ about:
77
+ 'Most courses show you what a model can do. This one makes you build the three features every product team is asked for — retrieval that answers the right question, output you can hand to other code, and an evaluation harness that catches regressions before your users do. Every module ends in something that runs; the reviewed project is a working extraction pipeline you can adapt.',
78
+ skills: [
79
+ 'Retrieval-augmented generation',
80
+ 'Structured output',
81
+ 'Evaluation harnesses',
82
+ 'Prompt design',
83
+ 'Vector search',
84
+ ],
85
+ };
86
+
87
+ const MODULES = [
88
+ {
89
+ id: 'foundations',
90
+ title: 'Foundations and tooling',
91
+ meta: '3 lessons · 48 min',
92
+ lessons: [
93
+ {
94
+ title: 'What a language model can and cannot promise',
95
+ duration: '12 min',
96
+ },
97
+ {
98
+ title: 'The working setup: SDK, keys, and a scratchpad',
99
+ duration: '18 min',
100
+ },
101
+ {title: 'Tokens, temperature, and the cost dial', duration: '18 min'},
102
+ ],
103
+ },
104
+ {
105
+ id: 'retrieval',
106
+ title: 'Retrieval that answers the question',
107
+ meta: '4 lessons · 76 min',
108
+ lessons: [
109
+ {
110
+ title: 'Chunking strategies that survive real documents',
111
+ duration: '21 min',
112
+ },
113
+ {title: 'Vector search from first principles', duration: '19 min'},
114
+ {title: 'Rerankers and the relevance ceiling', duration: '18 min'},
115
+ {title: 'When the model should say it found nothing', duration: '18 min'},
116
+ ],
117
+ },
118
+ {
119
+ id: 'output',
120
+ title: 'Structured output you can hand to code',
121
+ meta: '3 lessons · 64 min',
122
+ lessons: [
123
+ {title: 'Schemas as contracts, not suggestions', duration: '22 min'},
124
+ {title: 'Validation, repair, and retry loops', duration: '21 min'},
125
+ {title: 'Streaming partial results into a UI', duration: '21 min'},
126
+ ],
127
+ },
128
+ {
129
+ id: 'evaluation',
130
+ title: 'The evaluation harness',
131
+ meta: '3 lessons · 59 min',
132
+ lessons: [
133
+ {title: 'Golden sets and where they come from', duration: '20 min'},
134
+ {title: 'Scoring retrieval and generation apart', duration: '19 min'},
135
+ {title: 'Regression gates in continuous integration', duration: '20 min'},
136
+ ],
137
+ },
138
+ ];
139
+
140
+ const INCLUDES = [
141
+ 'Lifetime access to 13 lessons and all future revisions',
142
+ 'The reviewed project: a working extraction pipeline',
143
+ 'A graded certificate of completion',
144
+ 'Offline downloads for every lesson',
145
+ ];
146
+
147
+ const INSTRUCTOR = {
148
+ name: 'Gabriela Fernández',
149
+ role: 'Applied AI engineer, formerly search infrastructure',
150
+ portrait: '/template-assets/DATA-Gabriela-Fernandez.png',
151
+ learners: '12,840',
152
+ courses: '4',
153
+ rating: '4.9',
154
+ bio: 'Gabriela spent nine years building retrieval systems at scale before language models landed on her desk. She has shipped the exact pipeline this course teaches — twice, in production, with the scars to prove which parts matter.',
155
+ };
156
+
157
+ const ASPECTS = [
158
+ {label: 'Content depth', score: 4.9},
159
+ {label: 'Practical exercises', score: 4.8},
160
+ {label: 'Instructor clarity', score: 4.9},
161
+ {label: 'Pacing', score: 4.6},
162
+ {label: 'Materials and project', score: 4.7},
163
+ {label: 'Community support', score: 4.5},
164
+ ];
165
+
166
+ const REVIEWS = [
167
+ {
168
+ name: 'Drew Young',
169
+ avatar: '/template-assets/DATA-Drew-Young.png',
170
+ date: 'Reviewed 2 weeks ago',
171
+ rating: 5,
172
+ body: 'The evaluation module alone is worth the price. I walked in with a half-working RAG demo and walked out with a harness that caught two regressions in our first sprint after launch.',
173
+ helpful: 41,
174
+ },
175
+ {
176
+ name: 'Jihoo Song',
177
+ avatar: '/template-assets/DATA-Jihoo-Song.png',
178
+ date: 'Reviewed a month ago',
179
+ rating: 5,
180
+ body: 'Gabriela explains rerankers better than the papers do. The chunking lesson changed how our team splits documentation, and the project review was concrete, not rubber-stamp.',
181
+ helpful: 28,
182
+ },
183
+ {
184
+ name: 'Ana Thomas',
185
+ avatar: '/template-assets/DATA-Ana-Thomas.png',
186
+ date: 'Reviewed 2 months ago',
187
+ rating: 4,
188
+ body: 'Dense but fair. I wanted more on fine-tuning and got none — the course is honest about scoping to what ships. The structured-output lessons replaced three fragile regex pipelines of mine.',
189
+ helpful: 19,
190
+ },
191
+ {
192
+ name: 'Pablo Morales',
193
+ avatar: '/template-assets/DATA-Pablo-Morales.png',
194
+ date: 'Reviewed 3 months ago',
195
+ rating: 5,
196
+ body: 'I have taken four applied-AI courses this year and finished one. This is the one. Every module ends in something that runs, exactly as promised, and the finish-date picker kept me honest about pacing.',
197
+ helpful: 33,
198
+ },
199
+ ];
200
+
201
+ const FAQS = [
202
+ {
203
+ q: 'Do I need production access to a language model?',
204
+ a: 'No. Every exercise runs on a free-tier API key, and the project review accepts either the free tier or a local model. The course states where results differ between them.',
205
+ },
206
+ {
207
+ q: 'How long do I have access?',
208
+ a: 'Access is lifetime, including every future revision of the lessons. When the tooling shifts — and it will — the affected lessons are re-recorded rather than patched with errata.',
209
+ },
210
+ {
211
+ q: 'What if the course is not right for me?',
212
+ a: 'Every purchase carries a 30-day money-back guarantee, no questions and no forms beyond one click. Certificates already issued are yours to keep.',
213
+ },
214
+ {
215
+ q: 'Is there a certificate?',
216
+ a: 'Yes — a graded certificate issued when the reviewed project passes. It lists the pipeline you built, not just attendance.',
217
+ },
218
+ ];
219
+
220
+ const COHORTS = [
221
+ {
222
+ id: 'march',
223
+ date: 'Cohort of March 10',
224
+ detail: 'Live reviews · 14 seats left',
225
+ },
226
+ {
227
+ id: 'april',
228
+ date: 'Cohort of April 7',
229
+ detail: 'Live reviews · 6 seats left',
230
+ },
231
+ ];
232
+
233
+ // A fixed reference date keeps the computed finish date deterministic
234
+ // between server and client render.
235
+ const COHORT_START = new Date('2026-03-02T00:00:00');
236
+ const TOTAL_HOURS = 42;
237
+ const PACE_MIN = 2;
238
+ const PACE_MAX = 12;
239
+
240
+ const fmt = (n: number) => `$${n.toFixed(0)}`;
241
+
242
+ const finishFrom = (pace: number) => {
243
+ const weeks = Math.max(1, Math.ceil(TOTAL_HOURS / pace));
244
+ const finish = new Date(COHORT_START);
245
+ finish.setDate(finish.getDate() + weeks * 7);
246
+ return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} · finish around ${finish.toLocaleDateString(
247
+ 'en-US',
248
+ {month: 'short', day: 'numeric'},
249
+ )}`;
250
+ };
251
+
252
+ // ─── Star Rating ─────────────────────────────────────────────────────────────
253
+ function StarRating({rating, count}: {rating: number; count?: number}) {
254
+ const filled = Math.round(rating);
255
+ const empty = 5 - filled;
256
+
257
+ return (
258
+ <HStack gap={1} vAlign="center">
259
+ {Array.from({length: filled}, (_, i) => (
260
+ <Icon key={`full-${i}`} icon={StarIconSolid} size="sm" />
261
+ ))}
262
+ {Array.from({length: empty}, (_, i) => (
263
+ <Icon key={`empty-${i}`} icon={StarIcon} size="sm" />
264
+ ))}
265
+ {count === undefined && (
266
+ <VisuallyHidden>{`Rated ${rating} out of 5`}</VisuallyHidden>
267
+ )}
268
+ {count !== undefined && (
269
+ <Text type="body" color="secondary">
270
+ {rating} ({count.toLocaleString('en-US')})
271
+ </Text>
272
+ )}
273
+ </HStack>
274
+ );
275
+ }
276
+
277
+ function SectionHeader({title, action}: {title: string; action?: ReactNode}) {
278
+ return (
279
+ <HStack gap={2} vAlign="center" hAlign="between" wrap="wrap">
280
+ <Heading level={2}>{title}</Heading>
281
+ {action}
282
+ </HStack>
283
+ );
284
+ }
285
+
286
+ // ─── Course head: title, poster, facts ──────────────────────────────────────
287
+ function CourseHead() {
288
+ return (
289
+ <VStack gap={4}>
290
+ <VStack gap={2}>
291
+ <HStack gap={2} vAlign="center">
292
+ <Badge variant="warning" label="Bestseller" />
293
+ <Badge label={COURSE.level} />
294
+ </HStack>
295
+ <Heading level={1} maxLines={3}>
296
+ {COURSE.title}
297
+ </Heading>
298
+ <HStack gap={2} vAlign="center">
299
+ <StarRating rating={COURSE.rating} count={COURSE.ratingCount} />
300
+ <Text type="body" color="secondary">
301
+ · {COURSE.learnerCount} learners
302
+ </Text>
303
+ </HStack>
304
+ </VStack>
305
+ <AspectRatio ratio={16 / 9}>
306
+ <img
307
+ style={posterImage}
308
+ src="/template-assets/light-working-horizontal-1.png"
309
+ alt="Course preview: a laptop showing the extraction pipeline built in the project"
310
+ />
311
+ </AspectRatio>
312
+ <HStack gap={5} vAlign="center" wrap="wrap">
313
+ <HStack gap={1} vAlign="center">
314
+ <Icon icon={ClockIcon} size="sm" />
315
+ <Text type="body" color="secondary">
316
+ {COURSE.runtime}
317
+ </Text>
318
+ </HStack>
319
+ <HStack gap={1} vAlign="center">
320
+ <Icon icon={BookOpenIcon} size="sm" />
321
+ <Text type="body" color="secondary">
322
+ {COURSE.lessonCount} lessons
323
+ </Text>
324
+ </HStack>
325
+ <HStack gap={1} vAlign="center">
326
+ <Icon icon={GlobeAltIcon} size="sm" />
327
+ <Text type="body" color="secondary">
328
+ {COURSE.language}
329
+ </Text>
330
+ </HStack>
331
+ <HStack gap={1} vAlign="center">
332
+ <Icon icon={BoltIcon} size="sm" />
333
+ <Text type="body" color="secondary">
334
+ Self-paced or cohort
335
+ </Text>
336
+ </HStack>
337
+ </HStack>
338
+ </VStack>
339
+ );
340
+ }
341
+
342
+ // ─── About ───────────────────────────────────────────────────────────────────
343
+ function AboutSection() {
344
+ return (
345
+ <Section padding={6}>
346
+ <VStack gap={4}>
347
+ <SectionHeader title="About this course" />
348
+ <Text type="body">{COURSE.about}</Text>
349
+ <VStack gap={2}>
350
+ <Text type="label">Skills you practice</Text>
351
+ <HStack gap={2} wrap="wrap">
352
+ {COURSE.skills.map(skill => (
353
+ <Badge key={skill} label={skill} />
354
+ ))}
355
+ </HStack>
356
+ </VStack>
357
+ </VStack>
358
+ </Section>
359
+ );
360
+ }
361
+
362
+ // ─── Syllabus ────────────────────────────────────────────────────────────────
363
+ function SyllabusSection() {
364
+ return (
365
+ <Section padding={6}>
366
+ <VStack gap={4}>
367
+ <SectionHeader
368
+ title="Syllabus"
369
+ action={
370
+ <Text type="supporting" color="secondary">
371
+ {MODULES.length} modules · {COURSE.lessonCount} lessons
372
+ </Text>
373
+ }
374
+ />
375
+ <CollapsibleGroup type="multiple" defaultValue={[MODULES[0].id]}>
376
+ {MODULES.map(module => (
377
+ <Collapsible
378
+ key={module.id}
379
+ value={module.id}
380
+ trigger={
381
+ <VStack gap={1}>
382
+ <Text type="large" weight="bold">
383
+ {module.title}
384
+ </Text>
385
+ <Text type="supporting" color="secondary">
386
+ {module.meta}
387
+ </Text>
388
+ </VStack>
389
+ }>
390
+ <VStack gap={2}>
391
+ {module.lessons.map(lesson => (
392
+ <HStack
393
+ key={lesson.title}
394
+ gap={2}
395
+ vAlign="center"
396
+ hAlign="between">
397
+ <HStack gap={2} vAlign="center">
398
+ <Icon icon={PlayIcon} size="sm" />
399
+ <Text type="body">{lesson.title}</Text>
400
+ </HStack>
401
+ <Text type="supporting" color="secondary">
402
+ {lesson.duration}
403
+ </Text>
404
+ </HStack>
405
+ ))}
406
+ </VStack>
407
+ </Collapsible>
408
+ ))}
409
+ </CollapsibleGroup>
410
+ </VStack>
411
+ </Section>
412
+ );
413
+ }
414
+
415
+ // ─── Instructor ──────────────────────────────────────────────────────────────
416
+ function InstructorSection() {
417
+ return (
418
+ <Section padding={6}>
419
+ <VStack gap={4}>
420
+ <SectionHeader title="Your instructor" />
421
+ <Card padding={5}>
422
+ <VStack gap={4}>
423
+ <HStack gap={4} vAlign="start">
424
+ <Avatar
425
+ name={INSTRUCTOR.name}
426
+ src={INSTRUCTOR.portrait}
427
+ size="xl"
428
+ />
429
+ <VStack gap={1}>
430
+ <Text type="large" weight="bold">
431
+ {INSTRUCTOR.name}
432
+ </Text>
433
+ <Text type="body" color="secondary">
434
+ {INSTRUCTOR.role}
435
+ </Text>
436
+ </VStack>
437
+ </HStack>
438
+ <HStack gap={5} vAlign="center">
439
+ <HStack gap={1} vAlign="center">
440
+ <Icon icon={UsersIcon} size="sm" />
441
+ <Text type="body" color="secondary">
442
+ {INSTRUCTOR.learners} learners
443
+ </Text>
444
+ </HStack>
445
+ <HStack gap={1} vAlign="center">
446
+ <Icon icon={BookOpenIcon} size="sm" />
447
+ <Text type="body" color="secondary">
448
+ {INSTRUCTOR.courses} courses
449
+ </Text>
450
+ </HStack>
451
+ <HStack gap={1} vAlign="center">
452
+ <Icon icon={StarIcon} size="sm" />
453
+ <Text type="body" color="secondary">
454
+ {INSTRUCTOR.rating} instructor rating
455
+ </Text>
456
+ </HStack>
457
+ </HStack>
458
+ <Text type="body">{INSTRUCTOR.bio}</Text>
459
+ <HStack gap={2}>
460
+ <Button label="View full profile" />
461
+ <Button label="Follow" variant="ghost" />
462
+ </HStack>
463
+ </VStack>
464
+ </Card>
465
+ </VStack>
466
+ </Section>
467
+ );
468
+ }
469
+
470
+ // ─── Reviews ─────────────────────────────────────────────────────────────────
471
+ function ReviewCard({review}: {review: (typeof REVIEWS)[number]}) {
472
+ return (
473
+ <Card padding={4}>
474
+ <VStack gap={3}>
475
+ <HStack gap={2} vAlign="center">
476
+ <Avatar name={review.name} src={review.avatar} size="sm" />
477
+ <VStack gap={1}>
478
+ <Text type="body" weight="bold">
479
+ {review.name}
480
+ </Text>
481
+ <Text type="supporting" color="secondary">
482
+ {review.date}
483
+ </Text>
484
+ </VStack>
485
+ </HStack>
486
+ <StarRating rating={review.rating} />
487
+ <Text type="body">{review.body}</Text>
488
+ <HStack gap={2} vAlign="center">
489
+ <Button
490
+ label={`Helpful (${review.helpful})`}
491
+ variant="ghost"
492
+ size="sm"
493
+ icon={<Icon icon={CheckIcon} size="xsm" />}
494
+ />
495
+ </HStack>
496
+ </VStack>
497
+ </Card>
498
+ );
499
+ }
500
+
501
+ function ReviewsSection() {
502
+ return (
503
+ <Section padding={6}>
504
+ <VStack gap={4}>
505
+ <SectionHeader
506
+ title="Reviews"
507
+ action={
508
+ <Link href="#" type="supporting" color="secondary">
509
+ How reviews work
510
+ </Link>
511
+ }
512
+ />
513
+ <Card padding={5}>
514
+ <VStack gap={4}>
515
+ <HStack gap={5} vAlign="center">
516
+ <VStack gap={1}>
517
+ <Text type="display-3">{COURSE.rating}</Text>
518
+ <StarRating rating={COURSE.rating} />
519
+ <Text type="supporting" color="secondary">
520
+ {COURSE.ratingCount.toLocaleString('en-US')} reviews
521
+ </Text>
522
+ </VStack>
523
+ <StackItem size="fill">
524
+ <VStack gap={3}>
525
+ {ASPECTS.map(aspect => (
526
+ <VStack key={aspect.label} gap={1}>
527
+ <HStack gap={2} vAlign="center" hAlign="between">
528
+ <Text type="body" color="secondary">
529
+ {aspect.label}
530
+ </Text>
531
+ <Text type="body" color="secondary">
532
+ {aspect.score.toFixed(1)}
533
+ </Text>
534
+ </HStack>
535
+ <ProgressBar
536
+ label={aspect.label}
537
+ isLabelHidden
538
+ value={(aspect.score / 5) * 100}
539
+ />
540
+ </VStack>
541
+ ))}
542
+ </VStack>
543
+ </StackItem>
544
+ </HStack>
545
+ </VStack>
546
+ </Card>
547
+ <Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
548
+ {REVIEWS.map(review => (
549
+ <ReviewCard key={review.name} review={review} />
550
+ ))}
551
+ </Grid>
552
+ </VStack>
553
+ </Section>
554
+ );
555
+ }
556
+
557
+ // ─── FAQ ─────────────────────────────────────────────────────────────────────
558
+ function FaqSection() {
559
+ return (
560
+ <Section padding={6}>
561
+ <VStack gap={4}>
562
+ <SectionHeader title="Common questions" />
563
+ <CollapsibleGroup type="multiple">
564
+ {FAQS.map(faq => (
565
+ <Collapsible
566
+ key={faq.q}
567
+ value={faq.q}
568
+ defaultIsOpen={false}
569
+ trigger={
570
+ <Text type="large" weight="bold">
571
+ {faq.q}
572
+ </Text>
573
+ }>
574
+ <Text type="body">{faq.a}</Text>
575
+ </Collapsible>
576
+ ))}
577
+ </CollapsibleGroup>
578
+ </VStack>
579
+ </Section>
580
+ );
581
+ }
582
+
583
+ // ─── Enroll rail ─────────────────────────────────────────────────────────────
584
+ function EnrollRail() {
585
+ const [mode, setMode] = useState('self-paced');
586
+ const [pace, setPace] = useState(7);
587
+ const [cohort, setCohort] = useState(COHORTS[0].id);
588
+
589
+ return (
590
+ <Card padding={5}>
591
+ <VStack gap={4}>
592
+ <VStack gap={1}>
593
+ <HStack gap={2} vAlign="center">
594
+ <Text type="display-3">{fmt(COURSE.price)}</Text>
595
+ <Text type="body" color="secondary" hasStrikethrough>
596
+ {fmt(COURSE.compareAt)}
597
+ </Text>
598
+ <Badge variant="error" label="Sale" />
599
+ </HStack>
600
+ <Text type="supporting" color="secondary">
601
+ One-time purchase · yours to keep
602
+ </Text>
603
+ </VStack>
604
+ <VStack gap={2}>
605
+ <Button
606
+ label="Enroll now"
607
+ variant="primary"
608
+ size="lg"
609
+ icon={
610
+ <Icon icon={ArrowRightIcon} size="sm" xstyle={rtlStyles.mirror} />
611
+ }
612
+ />
613
+ <Button
614
+ label="Start lesson one free"
615
+ size="lg"
616
+ icon={<Icon icon={PlayIcon} size="sm" />}
617
+ />
618
+ </VStack>
619
+ <Text type="supporting" color="secondary">
620
+ 30-day money-back guarantee
621
+ </Text>
622
+ <Divider />
623
+ <VStack gap={3}>
624
+ <Text type="label">Start</Text>
625
+ <SegmentedControl value={mode} onChange={setMode} label="Start mode">
626
+ <SegmentedControlItem value="self-paced" label="Self-paced" />
627
+ <SegmentedControlItem value="cohort" label="Cohort" />
628
+ </SegmentedControl>
629
+ {mode === 'self-paced' ? (
630
+ <VStack gap={2}>
631
+ <Slider
632
+ label="Weekly pace"
633
+ value={pace}
634
+ onChange={setPace}
635
+ min={PACE_MIN}
636
+ max={PACE_MAX}
637
+ marks={[{value: PACE_MIN}, {value: PACE_MAX}]}
638
+ formatValue={(value: number) => `${value} h/week`}
639
+ />
640
+ <Text type="supporting" color="secondary">
641
+ At {pace} h/week — {finishFrom(pace)}
642
+ </Text>
643
+ </VStack>
644
+ ) : (
645
+ <RadioList label="Cohort start" value={cohort} onChange={setCohort}>
646
+ {COHORTS.map(option => (
647
+ <RadioListItem
648
+ key={option.id}
649
+ value={option.id}
650
+ label={option.date}
651
+ description={option.detail}
652
+ />
653
+ ))}
654
+ </RadioList>
655
+ )}
656
+ </VStack>
657
+ <Divider />
658
+ <VStack gap={2}>
659
+ <Text type="label">This course includes</Text>
660
+ {INCLUDES.map(item => (
661
+ <HStack key={item} gap={2} vAlign="start">
662
+ <Icon icon={CheckIcon} size="sm" />
663
+ <Text type="body" color="secondary">
664
+ {item}
665
+ </Text>
666
+ </HStack>
667
+ ))}
668
+ </VStack>
669
+ </VStack>
670
+ </Card>
671
+ );
672
+ }
673
+
674
+ // ─── Page ────────────────────────────────────────────────────────────────────
675
+ export default function CourseDetailTemplate() {
676
+ return (
677
+ <Layout
678
+ height="fill"
679
+ contentWidth={1200}
680
+ content={
681
+ <LayoutContent padding={6}>
682
+ <Grid columns={{minWidth: 320, repeat: 'fit'}} gap={6}>
683
+ <VStack gap={4}>
684
+ <CourseHead />
685
+ <AboutSection />
686
+ <SyllabusSection />
687
+ <InstructorSection />
688
+ <ReviewsSection />
689
+ <FaqSection />
690
+ </VStack>
691
+ <VStack gap={4} style={stickyRail}>
692
+ <EnrollRail />
693
+ </VStack>
694
+ </Grid>
695
+ </LayoutContent>
696
+ }
697
+ />
698
+ );
699
+ }