create-skybridge 0.0.0-dev.08be486 → 0.0.0-dev.09043e8

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 (75) hide show
  1. package/dist/index.js +52 -26
  2. package/package.json +1 -1
  3. package/templates/blank/package.json +2 -1
  4. package/templates/blank/vite.config.ts +1 -1
  5. package/templates/demo/package.json +2 -1
  6. package/templates/demo/vite.config.ts +1 -1
  7. package/templates/ecom/.dockerignore +4 -0
  8. package/templates/ecom/.env.template +7 -0
  9. package/templates/ecom/.ladle/components.tsx +26 -0
  10. package/templates/ecom/.ladle/config.mjs +11 -0
  11. package/templates/ecom/.ladle/vite.config.ts +11 -0
  12. package/templates/ecom/AGENTS.md +2 -0
  13. package/templates/ecom/Dockerfile +53 -0
  14. package/templates/ecom/README.md +92 -0
  15. package/templates/ecom/_gitignore +9 -0
  16. package/templates/ecom/alpic.json +3 -0
  17. package/templates/ecom/node_modules/.bin/alpic +21 -0
  18. package/templates/ecom/node_modules/.bin/ladle +21 -0
  19. package/templates/ecom/node_modules/.bin/sb +21 -0
  20. package/templates/ecom/node_modules/.bin/skybridge +21 -0
  21. package/templates/ecom/node_modules/.bin/tsc +21 -0
  22. package/templates/ecom/node_modules/.bin/tsserver +21 -0
  23. package/templates/ecom/node_modules/.bin/tsx +21 -0
  24. package/templates/ecom/node_modules/.bin/vite +21 -0
  25. package/templates/ecom/package.json +42 -0
  26. package/templates/ecom/src/catalog/index.ts +19 -0
  27. package/templates/ecom/src/catalog/mock.ts +134 -0
  28. package/templates/ecom/src/catalog/shopify.ts +264 -0
  29. package/templates/ecom/src/components/chip.css.ts +63 -0
  30. package/templates/ecom/src/components/chip.stories.tsx +27 -0
  31. package/templates/ecom/src/components/chip.tsx +49 -0
  32. package/templates/ecom/src/components/empty-state.stories.tsx +3 -0
  33. package/templates/ecom/src/components/empty-state.tsx +12 -0
  34. package/templates/ecom/src/components/expandable-text.css.ts +49 -0
  35. package/templates/ecom/src/components/expandable-text.stories.tsx +20 -0
  36. package/templates/ecom/src/components/expandable-text.tsx +53 -0
  37. package/templates/ecom/src/components/image-gallery.css.ts +172 -0
  38. package/templates/ecom/src/components/image-gallery.stories.tsx +30 -0
  39. package/templates/ecom/src/components/image-gallery.tsx +163 -0
  40. package/templates/ecom/src/components/product-card.css.ts +159 -0
  41. package/templates/ecom/src/components/product-card.stories.tsx +58 -0
  42. package/templates/ecom/src/components/product-card.tsx +102 -0
  43. package/templates/ecom/src/components/product-carousel.css.ts +134 -0
  44. package/templates/ecom/src/components/product-carousel.stories.tsx +64 -0
  45. package/templates/ecom/src/components/product-carousel.tsx +202 -0
  46. package/templates/ecom/src/components/variant-picker.css.ts +27 -0
  47. package/templates/ecom/src/components/variant-picker.stories.tsx +67 -0
  48. package/templates/ecom/src/components/variant-picker.tsx +70 -0
  49. package/templates/ecom/src/components/view-frame.css.ts +22 -0
  50. package/templates/ecom/src/components/view-frame.tsx +27 -0
  51. package/templates/ecom/src/config.ts +12 -0
  52. package/templates/ecom/src/design/contract.css.ts +39 -0
  53. package/templates/ecom/src/design/fonts.css +15 -0
  54. package/templates/ecom/src/design/primitives.css.ts +101 -0
  55. package/templates/ecom/src/design/recipes/typography.css.ts +75 -0
  56. package/templates/ecom/src/design/sprinkles.css.ts +128 -0
  57. package/templates/ecom/src/design/themes/dark.css.ts +36 -0
  58. package/templates/ecom/src/design/themes/light.css.ts +36 -0
  59. package/templates/ecom/src/design/tokens.ts +8 -0
  60. package/templates/ecom/src/helpers.ts +4 -0
  61. package/templates/ecom/src/i18n.ts +35 -0
  62. package/templates/ecom/src/index.css +9 -0
  63. package/templates/ecom/src/lib/cx.ts +9 -0
  64. package/templates/ecom/src/lib/format.ts +9 -0
  65. package/templates/ecom/src/lib/variants.ts +150 -0
  66. package/templates/ecom/src/server.ts +43 -0
  67. package/templates/ecom/src/tools/render-carousel.ts +161 -0
  68. package/templates/ecom/src/tools/search-products.ts +196 -0
  69. package/templates/ecom/src/types.ts +87 -0
  70. package/templates/ecom/src/views/carousel/detail/detail.css.ts +115 -0
  71. package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +133 -0
  72. package/templates/ecom/src/views/carousel/detail/index.tsx +254 -0
  73. package/templates/ecom/src/views/carousel/index.tsx +233 -0
  74. package/templates/ecom/tsconfig.json +11 -0
  75. package/templates/ecom/vite.config.ts +8 -0
@@ -0,0 +1,115 @@
1
+ import { style } from "@vanilla-extract/css";
2
+ import { colors, primitives } from "../../../design/tokens";
3
+
4
+ // Two-column breakpoint. Layout is driven by the pane's own width (container
5
+ // query), never the viewport, so a phone and a resized desktop pane agree.
6
+ const TWO_COLUMN_MIN_WIDTH = "560px";
7
+
8
+ export const detail = style({
9
+ containerType: "inline-size",
10
+ maxHeight: "100dvh",
11
+ overflowY: "auto",
12
+ padding: primitives.space.s,
13
+ // Clear the notch / home indicator when the page bleeds to the screen edge.
14
+ paddingBottom: `calc(${primitives.space.s} + env(safe-area-inset-bottom))`,
15
+ });
16
+
17
+ // Product / variant id, idiomatically top-right. @todo: reposition (drop the
18
+ // right align, or move it in the JSX) or remove it.
19
+ export const reference = style({
20
+ textAlign: "right",
21
+ color: colors.content.subtle,
22
+ marginBottom: primitives.space["3xs"],
23
+ });
24
+
25
+ // Single column by default; two columns (gallery | info) once the pane is wide.
26
+ export const grid = style({
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ gap: primitives.space.s,
30
+ maxWidth: "1099px",
31
+ marginInline: "auto",
32
+ "@container": {
33
+ [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: {
34
+ display: "grid",
35
+ // `minmax(0, 1fr)` (not `1fr`) lets a column shrink below its content's
36
+ // intrinsic width, so a chip row scrolls/wraps instead of blowing out.
37
+ gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
38
+ gridTemplateAreas: '"gallery info"',
39
+ alignItems: "start",
40
+ },
41
+ },
42
+ });
43
+
44
+ export const galleryCell = style({
45
+ "@container": {
46
+ [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: {
47
+ gridArea: "gallery",
48
+ position: "sticky",
49
+ top: primitives.space.s,
50
+ },
51
+ },
52
+ });
53
+
54
+ // The info column owns all inter-section spacing; sections set no outer margin.
55
+ export const info = style({
56
+ display: "flex",
57
+ flexDirection: "column",
58
+ gap: primitives.space.xs,
59
+ "@container": {
60
+ [`(min-width: ${TWO_COLUMN_MIN_WIDTH})`]: { gridArea: "info" },
61
+ },
62
+ });
63
+
64
+ export const title = style({ color: colors.content.intense });
65
+
66
+ export const price = style({ color: colors.content.intense });
67
+
68
+ // Product facts as a simple list: one line per fact, "label: value" (or just
69
+ // the value when unlabeled). marginTop separates it from the section heading.
70
+ // @todo: reshape freely (a table, grouped sections, inline chips…)
71
+ export const specList = style({
72
+ display: "flex",
73
+ flexDirection: "column",
74
+ gap: primitives.space["3xs"],
75
+ marginTop: primitives.space["3xs"],
76
+ marginBottom: 0,
77
+ });
78
+
79
+ // One fact on a line: "label:" then value.
80
+ export const specRow = style({
81
+ display: "flex",
82
+ gap: primitives.space["4xs"],
83
+ });
84
+
85
+ export const specLabel = style({
86
+ margin: 0,
87
+ color: colors.content.subtle,
88
+ });
89
+
90
+ export const specValue = style({
91
+ margin: 0,
92
+ color: colors.content.intense,
93
+ });
94
+
95
+ // Primary CTA. @todo: tune to your brand; add a secondary action beside it
96
+ // (ask-the-assistant, contact) as a per-catalog extension.
97
+ export const cta = style({
98
+ minHeight: "44px",
99
+ padding: `${primitives.space["3xs"]} ${primitives.space.s}`,
100
+ borderRadius: primitives.radius.m,
101
+ border: "none",
102
+ backgroundColor: colors.common.accent,
103
+ color: colors.content.invertIntense,
104
+ fontFamily: primitives.font.family.primary,
105
+ fontSize: primitives.font.size.m,
106
+ fontWeight: primitives.font.weight.medium,
107
+ cursor: "pointer",
108
+ selectors: {
109
+ "&:disabled": {
110
+ backgroundColor: colors.surface.intense,
111
+ color: colors.content.subtle,
112
+ cursor: "not-allowed",
113
+ },
114
+ },
115
+ });
@@ -0,0 +1,133 @@
1
+ import type { Product } from "../../../types.js";
2
+ import { DetailView } from "./index";
3
+
4
+ function shot(fill: string) {
5
+ return `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Crect width='400' height='400' fill='%23${fill}'/%3E%3Ccircle cx='200' cy='200' r='110' fill='%23ffffff' fill-opacity='0.5'/%3E%3C/svg%3E`;
6
+ }
7
+
8
+ // A long media set (primary shot + several accents) so the desktop thumbnail
9
+ // rail is taller than the image and overflows past its bottom.
10
+ const ACCENT_FILLS = [
11
+ "c9d4f5",
12
+ "f5d4c9",
13
+ "d4f5c9",
14
+ "f5f0c9",
15
+ "e0c9f5",
16
+ "c9f5f0",
17
+ "f5c9e0",
18
+ "d9d9d9",
19
+ ];
20
+
21
+ function galleryMedia(primaryFill: string): string[] {
22
+ const media = [shot(primaryFill)];
23
+ for (const fill of ACCENT_FILLS) {
24
+ media.push(shot(fill));
25
+ }
26
+ return media;
27
+ }
28
+
29
+ const DESCRIPTION =
30
+ "A relaxed-fit jacket in water-repellent cotton.\n\nDropped shoulders, a two-way zip, and ribbed cuffs. Fully lined, with two zip pockets at the front and one inside.";
31
+
32
+ // One story, every picker behavior:
33
+ // - Hole in the matrix: {sand,L} does not exist, so under Sand, L is
34
+ // hard-disabled; switching to Sand while L is selected snaps Size to M.
35
+ // - Sold out: {black,L} renders struck but selectable; the CTA locks as
36
+ // "Out of stock".
37
+ // - Axis not applicable: the indigo colorway is one-size (no `size` key), so
38
+ // choosing "Indigo" hides the Size row; indigo resolves and stays buyable.
39
+ function jacket(
40
+ id: string,
41
+ color: string,
42
+ colorLabel: string,
43
+ size: string | null,
44
+ outOfStock = false,
45
+ ): Product["variants"][number] {
46
+ const fill =
47
+ color === "sand" ? "d8c7a8" : color === "indigo" ? "3f4a8a" : "2b2b2b";
48
+ return {
49
+ id,
50
+ selection: size == null ? { color } : { color, size },
51
+ title: `Field jacket — ${colorLabel}`,
52
+ description: DESCRIPTION,
53
+ price: { amount: color === "black" ? 229 : 249, currency: "EUR" },
54
+ media: galleryMedia(fill),
55
+ url: "https://example.com/jacket",
56
+ outOfStock,
57
+ specs: [
58
+ { label: "Material", value: "Water-repellent cotton" },
59
+ { label: "Lining", value: "Recycled polyester" },
60
+ { label: "Fit", value: "Relaxed" },
61
+ { label: "Closure", value: "Two-way zip" },
62
+ { label: "Pockets", value: "Two zip front, one inside" },
63
+ { label: "Collar", value: "Stand-up" },
64
+ { label: "Cuffs", value: "Ribbed" },
65
+ { label: "Weight", value: "780 g" },
66
+ { label: "Origin", value: "Made in Portugal" },
67
+ { value: "Machine washable" }, // label-less fact (renders value only)
68
+ ],
69
+ };
70
+ }
71
+
72
+ const PRODUCT: Product = {
73
+ id: "field-jacket",
74
+ options: [
75
+ {
76
+ id: "color",
77
+ label: "Color",
78
+ values: [
79
+ { id: "black", label: "Black", media: shot("2b2b2b") },
80
+ { id: "sand", label: "Sand", media: shot("d8c7a8") },
81
+ { id: "indigo", label: "Indigo", media: shot("3f4a8a") },
82
+ ],
83
+ },
84
+ {
85
+ id: "size",
86
+ label: "Size",
87
+ values: [
88
+ { id: "m", label: "M" },
89
+ { id: "l", label: "L" },
90
+ ],
91
+ },
92
+ ],
93
+ variants: [
94
+ jacket("fj-black-m", "black", "Black", "m"),
95
+ jacket("fj-black-l", "black", "Black", "l", true),
96
+ jacket("fj-sand-m", "sand", "Sand", "m"),
97
+ jacket("fj-indigo", "indigo", "Indigo", null),
98
+ ],
99
+ card: {
100
+ title: "Field jacket",
101
+ price: { amount: 229, currency: "EUR" },
102
+ media: [shot("2b2b2b")],
103
+ specs: [],
104
+ },
105
+ };
106
+
107
+ export const Default = () => <DetailView product={PRODUCT} />;
108
+
109
+ // Single-variant product: no picker, buyable immediately.
110
+ const SIMPLE: Product = {
111
+ id: "tote",
112
+ options: [],
113
+ variants: [
114
+ {
115
+ id: "tote",
116
+ selection: {},
117
+ title: "Canvas tote",
118
+ description: "A sturdy everyday canvas tote.",
119
+ price: { amount: 39, currency: "EUR" },
120
+ media: [shot("e1e1e1")],
121
+ url: "https://example.com/tote",
122
+ specs: [{ label: "Material", value: "Canvas" }],
123
+ },
124
+ ],
125
+ card: {
126
+ title: "Canvas tote",
127
+ price: { amount: 39, currency: "EUR" },
128
+ media: [shot("e1e1e1")],
129
+ specs: [],
130
+ },
131
+ };
132
+
133
+ export const SingleVariant = () => <DetailView product={SIMPLE} />;
@@ -0,0 +1,254 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useOpenExternal, useSetOpenInAppUrl, useUser } from "skybridge/web";
3
+ import { ExpandableText } from "../../../components/expandable-text";
4
+ import { ImageGallery } from "../../../components/image-gallery";
5
+ import { VariantPicker } from "../../../components/variant-picker";
6
+ import { sprinkles, text } from "../../../design/tokens";
7
+ import { type Labels, useLabels } from "../../../i18n";
8
+ import { cx } from "../../../lib/cx";
9
+ import { formatPrice } from "../../../lib/format";
10
+ import {
11
+ initialSelection,
12
+ resolveVariant,
13
+ type Selection,
14
+ } from "../../../lib/variants.js";
15
+ import type { Product, Variant } from "../../../types.js";
16
+ import * as styles from "./detail.css";
17
+
18
+ // Price to show: the resolved variant's price, else the range across variants
19
+ // (or a single price when they agree), else the card price, else a fallback.
20
+ function priceText(
21
+ product: Product,
22
+ variantPrice: Product["card"]["price"],
23
+ locale: string,
24
+ labels: Labels,
25
+ ): string {
26
+ if (variantPrice) {
27
+ return formatPrice(variantPrice, locale);
28
+ }
29
+ const amounts: number[] = [];
30
+ let currency = "";
31
+ for (const variant of product.variants) {
32
+ if (variant.price) {
33
+ amounts.push(variant.price.amount);
34
+ currency = variant.price.currency;
35
+ }
36
+ }
37
+ if (amounts.length > 0) {
38
+ const min = Math.min(...amounts);
39
+ const max = Math.max(...amounts);
40
+ if (min === max) {
41
+ return formatPrice({ amount: min, currency }, locale);
42
+ }
43
+ return `${formatPrice({ amount: min, currency }, locale)} – ${formatPrice({ amount: max, currency }, locale)}`;
44
+ }
45
+ if (product.card.price) {
46
+ return formatPrice(product.card.price, locale);
47
+ }
48
+ return labels.priceOnRequest;
49
+ }
50
+
51
+ // data-llm narrates the variant the user is currently looking at. The full
52
+ // product spec (every variant) is pushed to view state by the carousel
53
+ // orchestrator, so the model can answer beyond what is on screen; this stays
54
+ // scoped to the visible selection.
55
+ function grounding(
56
+ product: Product,
57
+ variant: Variant | undefined,
58
+ title: string,
59
+ price: string,
60
+ selection: Selection,
61
+ unpurchasable: boolean,
62
+ labels: Labels,
63
+ ): string {
64
+ const parts = [`The user is viewing "${title}" (product id: ${product.id}).`];
65
+ const chosen: string[] = [];
66
+ for (const option of product.options) {
67
+ const valueId = selection[option.id];
68
+ let label: string | undefined;
69
+ for (const value of option.values) {
70
+ if (value.id === valueId) {
71
+ label = value.label;
72
+ break;
73
+ }
74
+ }
75
+ if (label === undefined) {
76
+ // Axis skipped by the shown variant vs simply not picked yet.
77
+ label =
78
+ variant != null && variant.selection[option.id] == null
79
+ ? "not applicable"
80
+ : "not selected";
81
+ }
82
+ chosen.push(`${option.label}: ${label}`);
83
+ }
84
+ if (chosen.length > 0) {
85
+ parts.push(`Selected — ${chosen.join(", ")}.`);
86
+ }
87
+ parts.push(`Price: ${price}.`);
88
+ if (unpurchasable) {
89
+ // Sold-out real variant vs a combination that does not exist.
90
+ parts.push(
91
+ variant != null ? labels.outOfStock : labels.combinationUnavailable,
92
+ );
93
+ }
94
+ return parts.join(" ");
95
+ }
96
+
97
+ /**
98
+ * Product detail view, rendered fullscreen over the carousel (see the carousel
99
+ * orchestrator). Reads a single product from the payload already in `_meta`;
100
+ * does no fetch. Option choices resolve against the product's sparse variant
101
+ * list in-place (no remount).
102
+ */
103
+ export function DetailView({ product }: { product: Product }) {
104
+ const { locale } = useUser();
105
+ const labels = useLabels();
106
+ const openExternal = useOpenExternal();
107
+ const setOpenInAppUrl = useSetOpenInAppUrl();
108
+ const [selection, setSelection] = useState<Selection>(() =>
109
+ initialSelection(product),
110
+ );
111
+
112
+ // The exact variant for the selection; card fields fill in when none
113
+ // resolves (the applyChoice edge case, or a product with no variants).
114
+ const shown = resolveVariant(product, selection);
115
+ const displayTitle = shown?.title ?? product.card.title;
116
+ const description = shown?.description ?? product.card.description;
117
+ const media = shown?.media.length ? shown.media : product.card.media;
118
+ const specs = shown?.specs ?? product.card.specs;
119
+ // Sold out, or no variant resolved.
120
+ const unpurchasable = shown ? (shown.outOfStock ?? false) : true;
121
+ const url = shown?.url ?? product.card.url;
122
+ // The shown item's id: the resolved variant's SKU, else the product id.
123
+ const reference = shown?.id ?? product.id;
124
+ // @todo: read any custom Meta fields you added the same way (shown first,
125
+ // then card), e.g. `const rating = shown?.rating ?? product.card.rating;`,
126
+ // then render them in the agreed spot (rating by the title, discount by the
127
+ // price, badges as chips…).
128
+
129
+ const price = priceText(product, shown?.price, locale, labels);
130
+ // Buy CTA is enabled only for an exact, in-stock variant with a real link
131
+ // (an empty url would leave the button enabled but dead).
132
+ const canBuy = shown != null && Boolean(url) && !unpurchasable;
133
+
134
+ // Point the host's fullscreen "Open in app" affordance at the selected
135
+ // variant's page. Apps-SDK only; MCP Apps hosts reject, so ignore that.
136
+ useEffect(() => {
137
+ if (url) {
138
+ setOpenInAppUrl(url).catch(() => {});
139
+ }
140
+ }, [url, setOpenInAppUrl]);
141
+
142
+ return (
143
+ <div
144
+ className={styles.detail}
145
+ data-llm={grounding(
146
+ product,
147
+ shown,
148
+ displayTitle,
149
+ price,
150
+ selection,
151
+ unpurchasable,
152
+ labels,
153
+ )}
154
+ >
155
+ {/* Product / variant reference, idiomatically top-right. @todo: move it
156
+ (e.g. into the info column under the title) or drop it; restyle in
157
+ detail.css.ts (`reference`). */}
158
+ <p className={cx(text({ style: "bodyS" }), styles.reference)}>
159
+ {labels.reference} {reference}
160
+ </p>
161
+
162
+ <div className={styles.grid}>
163
+ <div className={styles.galleryCell}>
164
+ {media.length > 0 ? (
165
+ // Key on the media set so switching to a variant with different
166
+ // images remounts the gallery fresh (index reset to the first image).
167
+ <ImageGallery
168
+ key={media.join("|")}
169
+ media={media}
170
+ alt={displayTitle}
171
+ />
172
+ ) : null}
173
+ </div>
174
+
175
+ <div className={styles.info}>
176
+ <h1
177
+ className={cx(
178
+ text({ style: "headingS", weight: "medium" }),
179
+ styles.title,
180
+ )}
181
+ >
182
+ {displayTitle}
183
+ </h1>
184
+
185
+ <p className={cx(text({ style: "headingS" }), styles.price)}>
186
+ {price}
187
+ </p>
188
+
189
+ <VariantPicker
190
+ product={product}
191
+ selection={selection}
192
+ onChange={setSelection}
193
+ />
194
+
195
+ {description ? <ExpandableText>{description}</ExpandableText> : null}
196
+
197
+ {/* Sold-out and unresolved selections stay composable; the CTA locks
198
+ and its label names the cause. */}
199
+ <button
200
+ type="button"
201
+ className={cx(styles.cta, sprinkles({ mt: "3xs" }))}
202
+ disabled={!canBuy}
203
+ onClick={canBuy && url ? () => openExternal(url) : undefined}
204
+ >
205
+ {shown
206
+ ? unpurchasable
207
+ ? labels.outOfStock
208
+ : labels.viewOnSite
209
+ : labels.combinationUnavailable}
210
+ </button>
211
+
212
+ {/* Product facts as a simple list, after the CTA: one "label: value"
213
+ line per fact (label-less facts show the value alone). This is the
214
+ visual treatment only; the full spec is already in view state for
215
+ the model (see the carousel orchestrator), so restyling or dropping
216
+ it never hides facts from the assistant. @todo: pick the shape that
217
+ best presents your specs: this list (default), a two-column table,
218
+ grouped sections, inline chips, etc. */}
219
+ {specs.length > 0 ? (
220
+ <section>
221
+ <h2 className={text({ style: "labelM", weight: "medium" })}>
222
+ {labels.specifications}
223
+ </h2>
224
+ <dl className={styles.specList}>
225
+ {specs.map((spec) => (
226
+ <div
227
+ key={`${spec.label ?? ""}:${spec.value}`}
228
+ className={styles.specRow}
229
+ >
230
+ {spec.label ? (
231
+ <dt
232
+ className={cx(
233
+ text({ style: "bodyS" }),
234
+ styles.specLabel,
235
+ )}
236
+ >
237
+ {spec.label}:
238
+ </dt>
239
+ ) : null}
240
+ <dd
241
+ className={cx(text({ style: "bodyS" }), styles.specValue)}
242
+ >
243
+ {spec.value}
244
+ </dd>
245
+ </div>
246
+ ))}
247
+ </dl>
248
+ </section>
249
+ ) : null}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ );
254
+ }