create-magic-storefront 0.5.0 → 0.6.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.
Files changed (139) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/AGENTS.md +32 -16
  4. package/template/DESIGN.md +117 -0
  5. package/template/PAGES.md +107 -29
  6. package/template/README.md +15 -3
  7. package/template/app/account/addresses/page.tsx +16 -0
  8. package/template/app/account/layout.tsx +14 -0
  9. package/template/app/account/loading.tsx +15 -0
  10. package/template/app/account/orders/[id]/page.tsx +15 -0
  11. package/template/app/account/page.tsx +12 -244
  12. package/template/app/account/profile/page.tsx +16 -0
  13. package/template/app/blog/(index)/loading.tsx +22 -0
  14. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  15. package/template/app/blog/[handle]/page.tsx +92 -56
  16. package/template/app/blog/author/[handle]/page.tsx +1 -1
  17. package/template/app/cart/layout.tsx +14 -0
  18. package/template/app/cart/page.tsx +49 -101
  19. package/template/app/checkout/layout.tsx +14 -0
  20. package/template/app/checkout/page.tsx +98 -281
  21. package/template/app/collections/[handle]/layout.tsx +20 -0
  22. package/template/app/collections/[handle]/loading.tsx +23 -0
  23. package/template/app/collections/[handle]/page.tsx +131 -15
  24. package/template/app/collections/page.tsx +49 -0
  25. package/template/app/contacts/page.tsx +196 -0
  26. package/template/app/error.tsx +19 -8
  27. package/template/app/fonts/OFL.txt +93 -0
  28. package/template/app/fonts/index.ts +14 -0
  29. package/template/app/fonts/onest-variable.woff2 +0 -0
  30. package/template/app/globals.css +14 -518
  31. package/template/app/layout.tsx +73 -50
  32. package/template/app/not-found.tsx +34 -4
  33. package/template/app/page.tsx +1 -1
  34. package/template/app/pages/[handle]/page.tsx +1 -1
  35. package/template/app/products/[handle]/layout.tsx +20 -0
  36. package/template/app/products/[handle]/loading.tsx +25 -0
  37. package/template/app/products/[handle]/page.tsx +112 -15
  38. package/template/app/providers.tsx +5 -1
  39. package/template/app/search/loading.tsx +12 -0
  40. package/template/app/search/page.tsx +138 -25
  41. package/template/app/sitemap.ts +5 -1
  42. package/template/app/styles/base.css +138 -0
  43. package/template/app/styles/components.css +1108 -0
  44. package/template/app/styles/layout.css +455 -0
  45. package/template/app/styles/pages.css +1839 -0
  46. package/template/app/styles/reset.css +55 -0
  47. package/template/app/styles/sections.css +415 -0
  48. package/template/app/styles/tokens.css +167 -0
  49. package/template/app/styles/utilities.css +37 -0
  50. package/template/app/wishlist/page.tsx +26 -0
  51. package/template/components/account/account-shell.tsx +100 -0
  52. package/template/components/account/addresses.tsx +247 -0
  53. package/template/components/account/order-detail.tsx +281 -0
  54. package/template/components/account/order-list.tsx +94 -0
  55. package/template/components/account/order-status.tsx +19 -0
  56. package/template/components/account/profile.tsx +129 -0
  57. package/template/components/account/sign-in.tsx +164 -0
  58. package/template/components/article-body.tsx +3 -2
  59. package/template/components/article-card.tsx +37 -12
  60. package/template/components/cart/cart-drawer.tsx +149 -0
  61. package/template/components/cart/cart-lines.tsx +89 -0
  62. package/template/components/cart/cart-summary.tsx +191 -0
  63. package/template/components/catalog/catalog-form.tsx +107 -0
  64. package/template/components/catalog/catalog-layout.tsx +82 -0
  65. package/template/components/catalog/facets-shell.tsx +60 -0
  66. package/template/components/catalog/facets.tsx +216 -0
  67. package/template/components/checkout/order-summary.tsx +84 -0
  68. package/template/components/checkout/radio-card.tsx +34 -0
  69. package/template/components/checkout/step-indicator.tsx +56 -0
  70. package/template/components/checkout/steps.tsx +264 -0
  71. package/template/components/checkout/thank-you.tsx +48 -0
  72. package/template/components/checkout/use-checkout.ts +90 -0
  73. package/template/components/footer/site-footer.tsx +132 -0
  74. package/template/components/header/cart-button.tsx +43 -0
  75. package/template/components/header/desktop-nav.tsx +133 -0
  76. package/template/components/header/mobile-menu.tsx +102 -0
  77. package/template/components/header/site-header.tsx +78 -0
  78. package/template/components/page-loading.tsx +20 -0
  79. package/template/components/pager.tsx +3 -1
  80. package/template/components/product/buy-box.tsx +148 -0
  81. package/template/components/product/gallery.tsx +179 -0
  82. package/template/components/product/pre-order-form.tsx +93 -0
  83. package/template/components/product/product-selection.tsx +31 -0
  84. package/template/components/product/recommendations.tsx +26 -0
  85. package/template/components/product/review-list.tsx +159 -0
  86. package/template/components/product/reviews.tsx +80 -0
  87. package/template/components/product/variant-picker.tsx +75 -0
  88. package/template/components/product-card.tsx +103 -0
  89. package/template/components/product-grid.tsx +27 -57
  90. package/template/components/quick-add.tsx +62 -0
  91. package/template/components/search/predictive-search.tsx +282 -0
  92. package/template/components/sections/announcement-bar.tsx +1 -1
  93. package/template/components/sections/banner.tsx +1 -1
  94. package/template/components/sections/collections.tsx +50 -13
  95. package/template/components/sections/deal-of-day.tsx +2 -2
  96. package/template/components/sections/product-shelves.tsx +13 -5
  97. package/template/components/sections/shoppable-stories.tsx +2 -2
  98. package/template/components/sections/store-reviews.tsx +10 -4
  99. package/template/components/share-button.tsx +42 -0
  100. package/template/components/ui/accordion.tsx +35 -0
  101. package/template/components/ui/badge.tsx +20 -0
  102. package/template/components/ui/breadcrumbs.tsx +31 -0
  103. package/template/components/ui/button.tsx +90 -0
  104. package/template/components/ui/drawer.tsx +84 -0
  105. package/template/components/ui/empty-state.tsx +27 -0
  106. package/template/components/ui/field.tsx +33 -0
  107. package/template/components/ui/icon.tsx +112 -0
  108. package/template/components/ui/price.tsx +35 -0
  109. package/template/components/ui/quantity-stepper.tsx +91 -0
  110. package/template/components/ui/rating.tsx +48 -0
  111. package/template/components/ui/skeleton.tsx +46 -0
  112. package/template/components/wishlist/wishlist-view.tsx +134 -0
  113. package/template/lib/article-outline.ts +35 -0
  114. package/template/lib/catalog-params.ts +170 -0
  115. package/template/lib/contacts.ts +34 -0
  116. package/template/lib/errors.ts +29 -0
  117. package/template/lib/i18n.ts +484 -0
  118. package/template/lib/menu.ts +70 -0
  119. package/template/lib/theme-settings.ts +36 -0
  120. package/template/theme/index.ts +7 -1
  121. package/template/theme/sections/collapsible-content.tsx +75 -0
  122. package/template/theme/sections/collection-list.tsx +32 -4
  123. package/template/theme/sections/featured-collection.tsx +22 -2
  124. package/template/theme/sections/hero.tsx +94 -7
  125. package/template/theme/sections/image-with-text.tsx +19 -3
  126. package/template/theme/sections/multicolumn.tsx +113 -0
  127. package/template/theme/sections/page-content.tsx +10 -4
  128. package/template/theme/sections/product-shelf.tsx +26 -4
  129. package/template/theme/sections/rich-text.tsx +13 -1
  130. package/template/theme/sections/testimonials.tsx +14 -9
  131. package/template/theme/settings.ts +85 -1
  132. package/template/theme/templates/index.json +4 -2
  133. package/template/.screenshots/home-1280.png +0 -0
  134. package/template/.screenshots/home-390.png +0 -0
  135. package/template/.screenshots/home-filled-1280.png +0 -0
  136. package/template/.screenshots/home-filled-390.png +0 -0
  137. package/template/.screenshots/page-1280.png +0 -0
  138. package/template/components/buy-box.tsx +0 -89
  139. package/template/components/cart-link.tsx +0 -15
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import {
4
+ MagicStoreError,
5
+ useMagicStore,
6
+ useStorefrontClient,
7
+ useWishlist,
8
+ } from '@magicstoreai/hydrogen';
9
+ import type { Schema } from '@magicstoreai/storefront-client';
10
+ import Link from 'next/link';
11
+ import { useEffect, useState } from 'react';
12
+
13
+ import { ProductCard } from '@/components/product-card';
14
+ import { buttonClass } from '@/components/ui/button';
15
+ import { EmptyState } from '@/components/ui/empty-state';
16
+ import { Icon } from '@/components/ui/icon';
17
+ import { Skeleton } from '@/components/ui/skeleton';
18
+ import { messages } from '@/lib/i18n';
19
+
20
+ type Product = Schema<'Product'>;
21
+
22
+ /** The API's page ceiling; a longer wishlist is asked for in several pages. */
23
+ const PER_REQUEST = 100;
24
+
25
+ /**
26
+ * The saved products: `useWishlist` holds ids, the products come from `productsIndex`
27
+ * (`filter[ids]`), in the wishlist's order. A product the shop no longer sells is left out. A
28
+ * guest's list lives on this device only, which the page says.
29
+ */
30
+ export function WishlistView() {
31
+ const { locale } = useMagicStore();
32
+ const t = messages(locale);
33
+ const client = useStorefrontClient();
34
+ const { productIds, owner, status, remove } = useWishlist();
35
+ const key = productIds.join(',');
36
+ const [result, setResult] = useState<{ key: string; products: Product[] } | null>(null);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ useEffect(() => {
40
+ let current = true;
41
+ const ids = key === '' ? [] : key.split(',');
42
+ const chunks = [];
43
+ for (let start = 0; start < ids.length; start += PER_REQUEST) {
44
+ chunks.push(ids.slice(start, start + PER_REQUEST));
45
+ }
46
+ Promise.all(
47
+ chunks.map((chunk) =>
48
+ client.productsIndex({
49
+ query: { 'filter[ids]': chunk.join(','), perPage: PER_REQUEST },
50
+ }),
51
+ ),
52
+ ).then(
53
+ (pages) => {
54
+ if (!current) {
55
+ return;
56
+ }
57
+ const byId = new Map(pages.flatMap((page) => page.data).map((p) => [p.id, p]));
58
+ setError(null);
59
+ setResult({
60
+ key,
61
+ products: ids.map((id) => byId.get(id)).filter((p): p is Product => p !== undefined),
62
+ });
63
+ },
64
+ (e: unknown) => {
65
+ if (current) {
66
+ setError(e instanceof MagicStoreError ? (e.detail ?? e.message) : t.somethingWentWrong);
67
+ }
68
+ },
69
+ );
70
+ return () => {
71
+ current = false;
72
+ };
73
+ }, [client, key, t.somethingWentWrong]);
74
+
75
+ if (error) {
76
+ return (
77
+ <p className="error" role="alert">
78
+ {error}
79
+ </p>
80
+ );
81
+ }
82
+ // Until the list and its products are in (a removal shows at once: the old products, filtered).
83
+ if (result === null || status === 'loading') {
84
+ return (
85
+ <ul className="product-grid" aria-busy="true">
86
+ {[0, 1, 2, 3].map((index) => (
87
+ <li key={index}>
88
+ <Skeleton ratio="var(--media-ratio)" />
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ );
93
+ }
94
+ const products = result.products.filter((product) => productIds.includes(product.id));
95
+ if (productIds.length === 0 || products.length === 0) {
96
+ return (
97
+ <EmptyState
98
+ title={t.wishlistEmpty}
99
+ icon="heart"
100
+ action={
101
+ <Link href="/collections" className={buttonClass({ variant: 'primary' })}>
102
+ {t.shopNow}
103
+ </Link>
104
+ }
105
+ >
106
+ {t.wishlistEmptyHint}
107
+ </EmptyState>
108
+ );
109
+ }
110
+ return (
111
+ <div className="stack stack--loose">
112
+ {owner === 'guest' && (
113
+ <p className="wishlist__hint">
114
+ {t.wishlistGuestHint} <Link href="/account">{t.signIn}</Link>
115
+ </p>
116
+ )}
117
+ <ul className="product-grid">
118
+ {products.map((product) => (
119
+ <li key={product.id} className="wishlist__item">
120
+ <ProductCard product={product} locale={locale} />
121
+ <button
122
+ type="button"
123
+ className="icon-button wishlist__remove"
124
+ aria-label={t.removeFromWishlist(product.title)}
125
+ onClick={() => void remove(product.id)}
126
+ >
127
+ <Icon name="close" size={18} />
128
+ </button>
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,35 @@
1
+ /** A heading of an article body, for its table of contents. */
2
+ export type OutlineHeading = { id: string; text: string; level: 2 | 3 };
3
+
4
+ const HEADING = /<h([23])(\s[^>]*)?>([\s\S]*?)<\/h\1>/gi;
5
+ const ENTITIES: Record<string, string> = {
6
+ '&amp;': '&',
7
+ '&lt;': '<',
8
+ '&gt;': '>',
9
+ '&quot;': '"',
10
+ '&#39;': "'",
11
+ '&nbsp;': ' ',
12
+ };
13
+
14
+ /**
15
+ * The article's `h2` / `h3` headings, each given an anchor (`section-1`, `section-2`…) in the
16
+ * returned HTML so a table of contents can link to it. The body is the server's sanitised
17
+ * `bodyHtml` (no scripts, no handlers); a heading that already has an `id` keeps it.
18
+ */
19
+ export function outlineArticle(bodyHtml: string): { html: string; headings: OutlineHeading[] } {
20
+ const headings: OutlineHeading[] = [];
21
+ const html = bodyHtml.replace(HEADING, (tag, level: string, attributes = '', inner: string) => {
22
+ const existing = /\sid="([^"]+)"/.exec(attributes)?.[1];
23
+ const id = existing ?? `section-${headings.length + 1}`;
24
+ const text = inner
25
+ .replace(/<[^>]+>/g, '')
26
+ .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (entity) => ENTITIES[entity] ?? entity)
27
+ .trim();
28
+ if (text === '') {
29
+ return tag;
30
+ }
31
+ headings.push({ id, text, level: level === '2' ? 2 : 3 });
32
+ return existing ? tag : `<h${level} id="${id}"${attributes}>${inner}</h${level}>`;
33
+ });
34
+ return { html, headings };
35
+ }
@@ -0,0 +1,170 @@
1
+ import type { Schema } from '@magicstoreai/storefront-client';
2
+
3
+ export type Filter = Schema<'Filter'>;
4
+
5
+ /** What a catalog page's URL says: the page, the order and the chosen filters. */
6
+ export type Catalog = {
7
+ page: number;
8
+ sort: SortKey | undefined;
9
+ /** By filter id (`vendor`, `options[Color]`, `inStock`): the chosen values, in URL order. */
10
+ selected: Record<string, string[]>;
11
+ price: { min: number | undefined; max: number | undefined };
12
+ };
13
+
14
+ /** The orders a catalog page offers; anything else in `?sort=` means the default order. */
15
+ export const SORT_KEYS = ['-createdAt', 'price', '-price', '-bestSellers', '-rating'] as const;
16
+ export type SortKey = (typeof SORT_KEYS)[number];
17
+
18
+ export type SearchParams = Record<string, string | string[] | undefined>;
19
+
20
+ /**
21
+ * A filter's URL parameter: the API's own key (`filter[vendor]`, `filter[options][Color]`), so a
22
+ * plain GET form builds the URL.
23
+ */
24
+ export function filterParam(id: string): string {
25
+ const option = OPTION_ID.exec(id);
26
+ return option ? `filter[options][${option[1]}]` : `filter[${id}]`;
27
+ }
28
+
29
+ /** An option filter's id: `options[<option name>]`. */
30
+ const OPTION_ID = /^options\[(.+)\]$/;
31
+
32
+ /** `q` of `search` / `searchFilters` (spec `maxLength`): a longer one is `VALIDATION_FAILED`. */
33
+ export const SEARCH_QUERY_MAX = 200;
34
+
35
+ export const PRICE_MIN = 'filter[price][min]';
36
+ export const PRICE_MAX = 'filter[price][max]';
37
+
38
+ function all(value: string | string[] | undefined): string[] {
39
+ return value === undefined ? [] : Array.isArray(value) ? value : [value];
40
+ }
41
+
42
+ function priceBound(value: string | string[] | undefined): number | undefined {
43
+ const raw = all(value)[0]?.trim();
44
+ if (!raw) {
45
+ return undefined;
46
+ }
47
+ const number = Number(raw);
48
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
49
+ }
50
+
51
+ /**
52
+ * Reads a catalog page's `searchParams`, keeping only what the API offers: filters and values the
53
+ * filters endpoint returned for this page, a known sort, a page of at least 1. Anything else (an
54
+ * old link, a hand-edited URL) is dropped, so the API never sees it and never answers
55
+ * `VALIDATION_FAILED`. A LIST value may repeat (`filter[vendor]=a&filter[vendor]=b`) or be
56
+ * comma-joined.
57
+ */
58
+ export function parseCatalog(params: SearchParams, filters: Filter[]): Catalog {
59
+ const selected: Record<string, string[]> = {};
60
+ let price: Catalog['price'] = { min: undefined, max: undefined };
61
+ for (const filter of filters) {
62
+ if (filter.type === 'PRICE_RANGE') {
63
+ price = { min: priceBound(params[PRICE_MIN]), max: priceBound(params[PRICE_MAX]) };
64
+ continue;
65
+ }
66
+ const given = all(params[filterParam(filter.id)]).flatMap((value) => value.split(','));
67
+ const allowed =
68
+ filter.type === 'BOOLEAN'
69
+ ? given.filter((value) => value === 'true')
70
+ : given.filter((value) => filter.values.some((option) => option.value === value));
71
+ const unique = [...new Set(allowed)];
72
+ if (unique.length > 0) {
73
+ selected[filter.id] = filter.type === 'BOOLEAN' ? ['true'] : unique;
74
+ }
75
+ }
76
+ const sort = SORT_KEYS.find((key) => key === all(params.sort)[0]);
77
+ const page = Math.max(1, Math.trunc(Number(all(params.page)[0])) || 1);
78
+ return { page, sort, selected, price };
79
+ }
80
+
81
+ /** Whether any filter is chosen (sort and page do not count). */
82
+ export function hasFilters(catalog: Catalog): boolean {
83
+ return (
84
+ Object.keys(catalog.selected).length > 0 ||
85
+ catalog.price.min !== undefined ||
86
+ catalog.price.max !== undefined
87
+ );
88
+ }
89
+
90
+ /**
91
+ * The catalog as the API's query: `filter[vendor]=a,b`, `filter[options][Color]=Red,Blue`,
92
+ * `filter[price][min]`, `filter[inStock]=true`, `sort`, `page`.
93
+ */
94
+ export function apiQuery(catalog: Catalog): Record<string, unknown> {
95
+ const query: Record<string, unknown> = { page: catalog.page };
96
+ const options: Record<string, string> = {};
97
+ for (const [id, values] of Object.entries(catalog.selected)) {
98
+ const option = OPTION_ID.exec(id);
99
+ if (option) {
100
+ options[option[1]!] = values.join(',');
101
+ } else {
102
+ query[filterParam(id)] = values.join(',');
103
+ }
104
+ }
105
+ if (Object.keys(options).length > 0) {
106
+ query['filter[options]'] = options;
107
+ }
108
+ if (catalog.price.min !== undefined) {
109
+ query[PRICE_MIN] = catalog.price.min;
110
+ }
111
+ if (catalog.price.max !== undefined) {
112
+ query[PRICE_MAX] = catalog.price.max;
113
+ }
114
+ if (catalog.sort !== undefined) {
115
+ query.sort = catalog.sort;
116
+ }
117
+ return query;
118
+ }
119
+
120
+ /**
121
+ * The page URL's query string for a catalog, `extra` first (the search page's `q`). Page 1 is left
122
+ * out, so every filtered or sorted link starts from the first page.
123
+ */
124
+ export function catalogQuery(catalog: Catalog, extra: Record<string, string> = {}): string {
125
+ const params = new URLSearchParams(extra);
126
+ if (catalog.sort !== undefined) {
127
+ params.set('sort', catalog.sort);
128
+ }
129
+ for (const [id, values] of Object.entries(catalog.selected)) {
130
+ for (const value of values) {
131
+ params.append(filterParam(id), value);
132
+ }
133
+ }
134
+ if (catalog.price.min !== undefined) {
135
+ params.set(PRICE_MIN, String(catalog.price.min));
136
+ }
137
+ if (catalog.price.max !== undefined) {
138
+ params.set(PRICE_MAX, String(catalog.price.max));
139
+ }
140
+ if (catalog.page > 1) {
141
+ params.set('page', String(catalog.page));
142
+ }
143
+ const query = params.toString();
144
+ return query === '' ? '' : `?${query}`;
145
+ }
146
+
147
+ /** The same catalog on another page. */
148
+ export function onPage(catalog: Catalog, page: number): Catalog {
149
+ return { ...catalog, page };
150
+ }
151
+
152
+ /** The catalog without one value of a filter (or the whole filter, or the price), back on page 1. */
153
+ export function without(catalog: Catalog, id: string | 'price', value?: string): Catalog {
154
+ if (id === 'price') {
155
+ return { ...catalog, page: 1, price: { min: undefined, max: undefined } };
156
+ }
157
+ const selected = { ...catalog.selected };
158
+ const remaining = value === undefined ? [] : (selected[id] ?? []).filter((v) => v !== value);
159
+ if (remaining.length > 0) {
160
+ selected[id] = remaining;
161
+ } else {
162
+ delete selected[id];
163
+ }
164
+ return { ...catalog, page: 1, selected };
165
+ }
166
+
167
+ /** No filters, the same order, page 1. */
168
+ export function cleared(catalog: Catalog): Catalog {
169
+ return { ...catalog, page: 1, selected: {}, price: { min: undefined, max: undefined } };
170
+ }
@@ -0,0 +1,34 @@
1
+ import type { Schema } from '@magicstoreai/storefront-client';
2
+
3
+ type Contacts = Schema<'Contacts'>;
4
+ type Social = Contacts['socials'][number];
5
+
6
+ /** Network names are brand names: the same in every language. */
7
+ export const SOCIAL_NAMES: Record<Social['network'], string> = {
8
+ TELEGRAM: 'Telegram',
9
+ INSTAGRAM: 'Instagram',
10
+ FACEBOOK: 'Facebook',
11
+ YOUTUBE: 'YouTube',
12
+ TIKTOK: 'TikTok',
13
+ };
14
+
15
+ /** `tel:` keeps only the digits and a leading plus; the shown number stays as the merchant wrote it. */
16
+ export function telHref(number: string): string {
17
+ return `tel:${number.replace(/[^\d+]/g, '')}`;
18
+ }
19
+
20
+ /** A plain map link to a point: no embedded map, no script. */
21
+ export function mapHref({ latitude, longitude }: { latitude: number; longitude: number }): string {
22
+ return `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`;
23
+ }
24
+
25
+ /** Whether the shop gave any way to reach it at all. */
26
+ export function hasContacts(contacts: Contacts): boolean {
27
+ return (
28
+ contacts.phones.length > 0 ||
29
+ contacts.email !== null ||
30
+ contacts.socials.length > 0 ||
31
+ contacts.headOffice !== null ||
32
+ contacts.branches.length > 0
33
+ );
34
+ }
@@ -6,6 +6,35 @@ export function describe(error: unknown, fallback: string): string {
6
6
  return error instanceof MagicStoreError ? (error.detail ?? error.message) : fallback;
7
7
  }
8
8
 
9
+ /**
10
+ * A refused form, split for the screen: `fields` holds the messages of the `VALIDATION_FAILED`
11
+ * members the form has an input for (shown under each input), and `summary` one line for the rest —
12
+ * a refused member with no input on screen (`paymentMethodId`, `gender`…) or any other failure.
13
+ * Without that line such a refusal would leave the buyer with no message at all.
14
+ */
15
+ export function formErrors(
16
+ error: unknown,
17
+ shown: readonly string[],
18
+ fallback: string,
19
+ ): { fields: Record<string, string>; summary: string | null } {
20
+ if (!(error instanceof MagicStoreError) || error.code !== 'VALIDATION_FAILED') {
21
+ return { fields: {}, summary: describe(error, fallback) };
22
+ }
23
+ const fields: Record<string, string> = {};
24
+ const rest: string[] = [];
25
+ for (const entry of error.errors) {
26
+ if (shown.includes(entry.field)) {
27
+ fields[entry.field] = entry.message;
28
+ } else {
29
+ rest.push(entry.message);
30
+ }
31
+ }
32
+ if (rest.length > 0) {
33
+ return { fields, summary: rest.join(' ') };
34
+ }
35
+ return { fields, summary: Object.keys(fields).length > 0 ? null : describe(error, fallback) };
36
+ }
37
+
9
38
  /**
10
39
  * Why sharing the Telegram phone (`POST /customer/phone-verification`) failed. `CONTACT_EXPIRED`
11
40
  * asks to share again; `CONTACT_INVALID`, `CONTACT_NOT_OWN`, `INVALID_PHONE` and `TAKEN` show the