create-magic-storefront 0.4.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 (143) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/.env.example +5 -0
  4. package/template/AGENTS.md +36 -17
  5. package/template/DESIGN.md +117 -0
  6. package/template/PAGES.md +116 -29
  7. package/template/README.md +15 -3
  8. package/template/app/account/addresses/page.tsx +16 -0
  9. package/template/app/account/layout.tsx +14 -0
  10. package/template/app/account/loading.tsx +15 -0
  11. package/template/app/account/orders/[id]/page.tsx +15 -0
  12. package/template/app/account/page.tsx +12 -244
  13. package/template/app/account/profile/page.tsx +16 -0
  14. package/template/app/blog/(index)/loading.tsx +22 -0
  15. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  16. package/template/app/blog/[handle]/page.tsx +92 -56
  17. package/template/app/blog/author/[handle]/page.tsx +1 -1
  18. package/template/app/cart/layout.tsx +14 -0
  19. package/template/app/cart/page.tsx +49 -101
  20. package/template/app/checkout/layout.tsx +14 -0
  21. package/template/app/checkout/page.tsx +98 -281
  22. package/template/app/collections/[handle]/layout.tsx +20 -0
  23. package/template/app/collections/[handle]/loading.tsx +23 -0
  24. package/template/app/collections/[handle]/page.tsx +131 -15
  25. package/template/app/collections/page.tsx +49 -0
  26. package/template/app/contacts/page.tsx +196 -0
  27. package/template/app/error.tsx +19 -8
  28. package/template/app/fonts/OFL.txt +93 -0
  29. package/template/app/fonts/index.ts +14 -0
  30. package/template/app/fonts/onest-variable.woff2 +0 -0
  31. package/template/app/globals.css +14 -518
  32. package/template/app/layout.tsx +74 -50
  33. package/template/app/not-found.tsx +34 -4
  34. package/template/app/page.tsx +1 -1
  35. package/template/app/pages/[handle]/page.tsx +1 -1
  36. package/template/app/products/[handle]/layout.tsx +20 -0
  37. package/template/app/products/[handle]/loading.tsx +25 -0
  38. package/template/app/products/[handle]/page.tsx +112 -15
  39. package/template/app/providers.tsx +20 -7
  40. package/template/app/search/loading.tsx +12 -0
  41. package/template/app/search/page.tsx +138 -25
  42. package/template/app/sitemap.ts +5 -1
  43. package/template/app/storefront-api/[...path]/route.ts +11 -66
  44. package/template/app/styles/base.css +138 -0
  45. package/template/app/styles/components.css +1108 -0
  46. package/template/app/styles/layout.css +455 -0
  47. package/template/app/styles/pages.css +1839 -0
  48. package/template/app/styles/reset.css +55 -0
  49. package/template/app/styles/sections.css +415 -0
  50. package/template/app/styles/tokens.css +167 -0
  51. package/template/app/styles/utilities.css +37 -0
  52. package/template/app/wishlist/page.tsx +26 -0
  53. package/template/components/account/account-shell.tsx +100 -0
  54. package/template/components/account/addresses.tsx +247 -0
  55. package/template/components/account/order-detail.tsx +281 -0
  56. package/template/components/account/order-list.tsx +94 -0
  57. package/template/components/account/order-status.tsx +19 -0
  58. package/template/components/account/profile.tsx +129 -0
  59. package/template/components/account/sign-in.tsx +164 -0
  60. package/template/components/article-body.tsx +3 -2
  61. package/template/components/article-card.tsx +37 -12
  62. package/template/components/cart/cart-drawer.tsx +149 -0
  63. package/template/components/cart/cart-lines.tsx +89 -0
  64. package/template/components/cart/cart-summary.tsx +191 -0
  65. package/template/components/catalog/catalog-form.tsx +107 -0
  66. package/template/components/catalog/catalog-layout.tsx +82 -0
  67. package/template/components/catalog/facets-shell.tsx +60 -0
  68. package/template/components/catalog/facets.tsx +216 -0
  69. package/template/components/checkout/order-summary.tsx +84 -0
  70. package/template/components/checkout/radio-card.tsx +34 -0
  71. package/template/components/checkout/step-indicator.tsx +56 -0
  72. package/template/components/checkout/steps.tsx +264 -0
  73. package/template/components/checkout/thank-you.tsx +48 -0
  74. package/template/components/checkout/use-checkout.ts +90 -0
  75. package/template/components/footer/site-footer.tsx +132 -0
  76. package/template/components/header/cart-button.tsx +43 -0
  77. package/template/components/header/desktop-nav.tsx +133 -0
  78. package/template/components/header/mobile-menu.tsx +102 -0
  79. package/template/components/header/site-header.tsx +78 -0
  80. package/template/components/page-loading.tsx +20 -0
  81. package/template/components/pager.tsx +3 -1
  82. package/template/components/product/buy-box.tsx +148 -0
  83. package/template/components/product/gallery.tsx +179 -0
  84. package/template/components/product/pre-order-form.tsx +93 -0
  85. package/template/components/product/product-selection.tsx +31 -0
  86. package/template/components/product/recommendations.tsx +26 -0
  87. package/template/components/product/review-list.tsx +159 -0
  88. package/template/components/product/reviews.tsx +80 -0
  89. package/template/components/product/variant-picker.tsx +75 -0
  90. package/template/components/product-card.tsx +103 -0
  91. package/template/components/product-grid.tsx +27 -57
  92. package/template/components/quick-add.tsx +62 -0
  93. package/template/components/search/predictive-search.tsx +282 -0
  94. package/template/components/sections/announcement-bar.tsx +1 -1
  95. package/template/components/sections/banner.tsx +1 -1
  96. package/template/components/sections/collections.tsx +50 -13
  97. package/template/components/sections/deal-of-day.tsx +2 -2
  98. package/template/components/sections/product-shelves.tsx +13 -5
  99. package/template/components/sections/shoppable-stories.tsx +2 -2
  100. package/template/components/sections/store-reviews.tsx +10 -4
  101. package/template/components/share-button.tsx +42 -0
  102. package/template/components/ui/accordion.tsx +35 -0
  103. package/template/components/ui/badge.tsx +20 -0
  104. package/template/components/ui/breadcrumbs.tsx +31 -0
  105. package/template/components/ui/button.tsx +90 -0
  106. package/template/components/ui/drawer.tsx +84 -0
  107. package/template/components/ui/empty-state.tsx +27 -0
  108. package/template/components/ui/field.tsx +33 -0
  109. package/template/components/ui/icon.tsx +112 -0
  110. package/template/components/ui/price.tsx +35 -0
  111. package/template/components/ui/quantity-stepper.tsx +91 -0
  112. package/template/components/ui/rating.tsx +48 -0
  113. package/template/components/ui/skeleton.tsx +46 -0
  114. package/template/components/wishlist/wishlist-view.tsx +134 -0
  115. package/template/lib/article-outline.ts +35 -0
  116. package/template/lib/catalog-params.ts +170 -0
  117. package/template/lib/contacts.ts +34 -0
  118. package/template/lib/errors.ts +29 -0
  119. package/template/lib/i18n.ts +484 -0
  120. package/template/lib/menu.ts +70 -0
  121. package/template/lib/theme-settings.ts +36 -0
  122. package/template/lib/upstream.ts +21 -0
  123. package/template/llms.txt +13 -0
  124. package/template/theme/index.ts +7 -1
  125. package/template/theme/sections/collapsible-content.tsx +75 -0
  126. package/template/theme/sections/collection-list.tsx +32 -4
  127. package/template/theme/sections/featured-collection.tsx +22 -2
  128. package/template/theme/sections/hero.tsx +94 -7
  129. package/template/theme/sections/image-with-text.tsx +19 -3
  130. package/template/theme/sections/multicolumn.tsx +113 -0
  131. package/template/theme/sections/page-content.tsx +10 -4
  132. package/template/theme/sections/product-shelf.tsx +26 -4
  133. package/template/theme/sections/rich-text.tsx +13 -1
  134. package/template/theme/sections/testimonials.tsx +14 -9
  135. package/template/theme/settings.ts +85 -1
  136. package/template/theme/templates/index.json +4 -2
  137. package/template/.screenshots/home-1280.png +0 -0
  138. package/template/.screenshots/home-390.png +0 -0
  139. package/template/.screenshots/home-filled-1280.png +0 -0
  140. package/template/.screenshots/home-filled-390.png +0 -0
  141. package/template/.screenshots/page-1280.png +0 -0
  142. package/template/components/buy-box.tsx +0 -89
  143. package/template/components/cart-link.tsx +0 -15
@@ -0,0 +1,36 @@
1
+ import { loadThemeSettings } from '@magicstoreai/hydrogen/server';
2
+ import type { SettingValues } from '@magicstoreai/hydrogen/theme';
3
+ import { cache } from 'react';
4
+
5
+ import { api, locale } from '@/lib/api';
6
+ import { themePreview } from '@/lib/preview';
7
+ import theme from '@/theme';
8
+ import type { settings } from '@/theme/settings';
9
+
10
+ export type GlobalSettings = SettingValues<typeof settings>;
11
+
12
+ /**
13
+ * The theme's global settings (`theme/settings.ts`) as the merchant saved them, the admin's draft
14
+ * while it previews. Every value is resolved: a missing or unknown one falls back to its default.
15
+ * Read once per request, however many server components ask.
16
+ */
17
+ export const globalSettings = cache(async (): Promise<GlobalSettings> => {
18
+ const preview = await themePreview();
19
+ return (await loadThemeSettings(api, theme, { locale, preview })) as GlobalSettings;
20
+ });
21
+
22
+ /**
23
+ * The visual settings as `data-*` attributes for <html>, read by `app/styles/tokens.css` and the
24
+ * component styles. An attribute is left out for its off state, never set to an empty string.
25
+ */
26
+ export function themeAttributes(values: GlobalSettings): Record<`data-${string}`, string> {
27
+ return {
28
+ 'data-radius': values.cornerRadius.toLowerCase(),
29
+ 'data-image-ratio': values.productImageRatio.toLowerCase(),
30
+ 'data-card-style': values.cardStyle.toLowerCase(),
31
+ 'data-cart-type': values.cartType.toLowerCase(),
32
+ ...(values.stickyHeader && { 'data-sticky-header': '' }),
33
+ ...(values.showVendor && { 'data-show-vendor': '' }),
34
+ ...(values.showRating && { 'data-show-rating': '' }),
35
+ };
36
+ }
@@ -10,3 +10,24 @@ export function upstreamBaseUrl(): string | null {
10
10
  const domain = process.env.MAGICSTORE_SHOP_DOMAIN;
11
11
  return domain ? `https://${domain}/api/v2/storefront` : null;
12
12
  }
13
+
14
+ /**
15
+ * Who holds the buyer's credentials. `'server'` (the default): `app/storefront-api` keeps the session
16
+ * tokens and the cart and checkout ids in httpOnly cookies, and the browser only sees "current".
17
+ * `MAGICSTORE_CREDENTIALS=browser` opts out. A public storefront token sends the browser straight to
18
+ * the API, past the proxy, so it means `'browser'` — and asking for `'server'` with one is an error.
19
+ */
20
+ export function credentialsMode(): 'browser' | 'server' {
21
+ const explicit = process.env.MAGICSTORE_CREDENTIALS || null;
22
+ const publicToken = Boolean(process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN);
23
+ if (explicit !== null && explicit !== 'server' && explicit !== 'browser') {
24
+ throw new Error(`MAGICSTORE_CREDENTIALS must be "server" or "browser", not "${explicit}".`);
25
+ }
26
+ if (explicit === 'server' && publicToken) {
27
+ throw new Error(
28
+ 'MAGICSTORE_CREDENTIALS=server needs every browser call to go through app/storefront-api: ' +
29
+ 'unset NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN, or set MAGICSTORE_CREDENTIALS=browser.',
30
+ );
31
+ }
32
+ return explicit === 'browser' || publicToken ? 'browser' : 'server';
33
+ }
package/template/llms.txt CHANGED
@@ -101,6 +101,18 @@ The cart id and the checkout id are credentials. Never log them and never put th
101
101
  query string. A guest proves an order with the `X-Checkout-Id` header. The same goes for tokens,
102
102
  OTP codes and full phone numbers.
103
103
 
104
+ Who holds them is a choice (`MagicStoreProvider credentials`):
105
+
106
+ - `'server'` — the starter's default. Every browser call goes through a same-origin
107
+ `createStorefrontProxy({ credentials: 'server' })` (`/server`; the starter's
108
+ `app/storefront-api`). It keeps the access and refresh tokens and the cart and checkout ids in
109
+ httpOnly cookies; JavaScript, `localStorage` and every body it sees hold the literal `"current"`
110
+ instead (`cart.id === 'current'`, `checkoutsStore({ body: { cartId: cart.id } })` just works). A
111
+ 401 the proxy flags with `x-session-expired` refreshes once and retries once; the proxy never
112
+ refreshes on its own.
113
+ - `'browser'` — the tokens and the cart id live in `localStorage`. Required when a public
114
+ storefront token sends the browser straight to the API (the proxy is not in the path).
115
+
104
116
  ## Cart → checkout → payment
105
117
 
106
118
  1. The first `useCart().addLine(…)` creates the cart; its id is stored for you. Every cart answer
@@ -142,6 +154,7 @@ for buyers), quote `error.requestId` when reporting. GET calls retry on 429 / 5x
142
154
  | `OTP_INVALID` | Let the buyer re-type the code. |
143
155
  | `OTP_EXPIRED` | Ask for a new code. |
144
156
  | `NETWORK_ERROR`, `ABORTED` | No answer (client-side): offer to try again. |
157
+ | `INVALID_RESPONSE` | The API answered 2xx with an unreadable body: report it. |
145
158
 
146
159
  The full list, with the `meta` each code carries, is in the backend's `docs/api/v2/standards.md`.
147
160
 
@@ -1,9 +1,11 @@
1
1
  import { defineTheme, type SectionComponents } from '@magicstoreai/hydrogen/theme';
2
2
 
3
+ import { CollapsibleContent, collapsibleContent } from './sections/collapsible-content';
3
4
  import { CollectionList, collectionList } from './sections/collection-list';
4
5
  import { FeaturedCollection, featuredCollection } from './sections/featured-collection';
5
6
  import { Hero, hero } from './sections/hero';
6
7
  import { ImageWithText, imageWithText } from './sections/image-with-text';
8
+ import { Multicolumn, multicolumn } from './sections/multicolumn';
7
9
  import { pageContent } from './sections/page-content';
8
10
  import { PlatformHome, platformHome } from './sections/platform-home';
9
11
  import { ProductShelf, productShelf } from './sections/product-shelf';
@@ -20,7 +22,7 @@ import page from './templates/page.json';
20
22
  */
21
23
  const theme = defineTheme({
22
24
  name: 'starter',
23
- version: '1.0.0',
25
+ version: '1.2.0',
24
26
  sections: [
25
27
  hero,
26
28
  richText,
@@ -29,6 +31,8 @@ const theme = defineTheme({
29
31
  collectionList,
30
32
  testimonials,
31
33
  productShelf,
34
+ multicolumn,
35
+ collapsibleContent,
32
36
  pageContent,
33
37
  platformHome,
34
38
  ],
@@ -49,5 +53,7 @@ export const components: SectionComponents<typeof theme.sections> = {
49
53
  'collection-list': CollectionList,
50
54
  testimonials: Testimonials,
51
55
  'product-shelf': ProductShelf,
56
+ multicolumn: Multicolumn,
57
+ 'collapsible-content': CollapsibleContent,
52
58
  'platform-home': PlatformHome,
53
59
  };
@@ -0,0 +1,75 @@
1
+ import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
2
+
3
+ import { Accordion } from '@/components/ui/accordion';
4
+
5
+ import { label } from '../text';
6
+
7
+ export const rowBlock = defineBlock({
8
+ type: 'row',
9
+ name: label('Row', 'Строка', 'Qator'),
10
+ settings: [
11
+ {
12
+ id: 'heading',
13
+ type: 'TEXT',
14
+ label: label('Question or heading', 'Вопрос или заголовок', 'Savol yoki sarlavha'),
15
+ maxLength: 200,
16
+ default: '',
17
+ },
18
+ { id: 'content', type: 'RICHTEXT', label: label('Answer', 'Ответ', 'Javob'), default: '' },
19
+ ],
20
+ });
21
+
22
+ export const collapsibleContent = defineSection({
23
+ type: 'collapsible-content',
24
+ name: label('Collapsible content', 'Раскрывающийся текст', 'Ochiladigan matn'),
25
+ icon: 'TEXT',
26
+ category: 'TEXT',
27
+ maxBlocks: 30,
28
+ settings: [
29
+ { id: 'heading', type: 'TEXT', label: label('Heading', 'Заголовок', 'Sarlavha'), default: '' },
30
+ {
31
+ id: 'openFirst',
32
+ type: 'CHECKBOX',
33
+ label: label('Open the first row', 'Раскрыть первую строку', 'Birinchi qatorni ochish'),
34
+ default: false,
35
+ },
36
+ ],
37
+ blocks: [rowBlock],
38
+ presets: [
39
+ {
40
+ name: label('FAQ', 'Вопросы и ответы', 'Savol va javoblar'),
41
+ blocks: [{ type: 'row' }, { type: 'row' }, { type: 'row' }],
42
+ },
43
+ ],
44
+ });
45
+
46
+ /** Rows that open one at a time (a FAQ, delivery terms); a row without both parts is left out. */
47
+ export function CollapsibleContent({
48
+ id,
49
+ settings,
50
+ blocks,
51
+ }: SectionProps<typeof collapsibleContent>) {
52
+ const rows = blocks.filter((block) => block.settings.heading && block.settings.content);
53
+ if (rows.length === 0) {
54
+ return null;
55
+ }
56
+ return (
57
+ <section className="collapsible-content">
58
+ {settings.heading && (
59
+ <div className="section-header">
60
+ <h2>{settings.heading}</h2>
61
+ </div>
62
+ )}
63
+ {rows.map(({ id: rowId, settings: row }, index) => (
64
+ <Accordion
65
+ key={rowId}
66
+ title={row.heading}
67
+ name={`collapsible-${id}`}
68
+ open={settings.openFirst && index === 0}
69
+ >
70
+ <div className="prose" dangerouslySetInnerHTML={{ __html: row.content }} />
71
+ </Accordion>
72
+ ))}
73
+ </section>
74
+ );
75
+ }
@@ -2,7 +2,8 @@ import { MagicStoreError } from '@magicstoreai/storefront-client';
2
2
  import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
3
3
 
4
4
  import { CollectionTiles } from '@/components/sections/collections';
5
- import { api } from '@/lib/api';
5
+ import { api, locale } from '@/lib/api';
6
+ import { messages } from '@/lib/i18n';
6
7
 
7
8
  import { label } from '../text';
8
9
 
@@ -22,6 +23,25 @@ export const collectionList = defineSection({
22
23
  maxBlocks: 8,
23
24
  settings: [
24
25
  { id: 'heading', type: 'TEXT', label: label('Heading', 'Заголовок', 'Sarlavha'), default: '' },
26
+ {
27
+ id: 'columns',
28
+ type: 'RANGE',
29
+ label: label('Columns on desktop', 'Колонок на компьютере', 'Kompyuterda ustunlar'),
30
+ min: 2,
31
+ max: 6,
32
+ step: 1,
33
+ default: 4,
34
+ },
35
+ {
36
+ id: 'showCount',
37
+ type: 'CHECKBOX',
38
+ label: label(
39
+ 'Show product count',
40
+ 'Показывать число товаров',
41
+ "Mahsulotlar sonini ko'rsatish",
42
+ ),
43
+ default: false,
44
+ },
25
45
  ],
26
46
  blocks: [collectionBlock],
27
47
  presets: [
@@ -56,9 +76,17 @@ export async function CollectionList({ settings, blocks }: SectionProps<typeof c
56
76
  return null;
57
77
  }
58
78
  return (
59
- <section className="stack">
60
- {settings.heading && <h2>{settings.heading}</h2>}
61
- <CollectionTiles collections={found} />
79
+ <section>
80
+ {settings.heading && (
81
+ <div className="section-header">
82
+ <h2>{settings.heading}</h2>
83
+ </div>
84
+ )}
85
+ <CollectionTiles
86
+ collections={found}
87
+ columns={settings.columns}
88
+ count={settings.showCount ? messages(locale).productsCount : undefined}
89
+ />
62
90
  </section>
63
91
  );
64
92
  }
@@ -32,11 +32,30 @@ export const featuredCollection = defineSection({
32
32
  step: 1,
33
33
  default: 8,
34
34
  },
35
+ {
36
+ id: 'layout',
37
+ type: 'SELECT',
38
+ label: label('Layout', 'Вид', "Ko'rinish"),
39
+ default: 'GRID',
40
+ options: [
41
+ { value: 'GRID', label: label('Grid', 'Сетка', 'Panjara') },
42
+ { value: 'SLIDER', label: label('Slider', 'Карусель', 'Karusel') },
43
+ ],
44
+ },
45
+ {
46
+ id: 'showViewAll',
47
+ type: 'CHECKBOX',
48
+ label: label('Link to the collection', 'Ссылка на коллекцию', "To'plamga havola"),
49
+ default: true,
50
+ },
35
51
  ],
36
52
  presets: [{ name: label('Featured collection', 'Подборка из коллекции', "To'plamdan tanlov") }],
37
53
  });
38
54
 
39
- /** Products of the collection the merchant picked; nothing until one is picked. */
55
+ /**
56
+ * Products of the collection the merchant picked, as a grid or a sideways row, with a link to the
57
+ * collection; nothing until one is picked.
58
+ */
40
59
  export async function FeaturedCollection({ settings }: SectionProps<typeof featuredCollection>) {
41
60
  const { collection } = settings;
42
61
  if (collection === null) {
@@ -49,8 +68,9 @@ export async function FeaturedCollection({ settings }: SectionProps<typeof featu
49
68
  return (
50
69
  <Shelf
51
70
  title={settings.heading || collection.title}
52
- href={`/collections/${collection.handle}`}
71
+ href={settings.showViewAll ? `/collections/${collection.handle}` : undefined}
53
72
  products={data}
73
+ slider={settings.layout === 'SLIDER'}
54
74
  />
55
75
  );
56
76
  }
@@ -1,6 +1,7 @@
1
1
  import { Image } from '@magicstoreai/hydrogen';
2
2
  import { defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
3
3
  import Link from 'next/link';
4
+ import type { CSSProperties } from 'react';
4
5
 
5
6
  import { api } from '@/lib/api';
6
7
 
@@ -69,23 +70,109 @@ export const hero = defineSection({
69
70
  { value: 'CENTER', label: label('Center', 'По центру', 'Markazda') },
70
71
  ],
71
72
  },
73
+ {
74
+ id: 'layout',
75
+ type: 'SELECT',
76
+ label: label('Text', 'Текст', 'Matn'),
77
+ info: label(
78
+ 'Below the image suits a banner that already has text on it.',
79
+ 'Под изображением — для баннера, на котором уже есть текст.',
80
+ 'Rasm ostida — ustida matni bor banner uchun.',
81
+ ),
82
+ default: 'OVERLAY',
83
+ options: [
84
+ { value: 'OVERLAY', label: label('Over the image', 'Поверх изображения', 'Rasm ustida') },
85
+ { value: 'STACKED', label: label('Below the image', 'Под изображением', 'Rasm ostida') },
86
+ ],
87
+ },
88
+ {
89
+ id: 'stackOnMobile',
90
+ type: 'CHECKBOX',
91
+ label: label(
92
+ 'Text below the image on phones',
93
+ 'На телефонах текст под изображением',
94
+ 'Telefonlarda matn rasm ostida',
95
+ ),
96
+ default: true,
97
+ },
98
+ {
99
+ id: 'contentPosition',
100
+ type: 'SELECT',
101
+ label: label('Text position', 'Положение текста', 'Matn joylashuvi'),
102
+ default: 'BOTTOM',
103
+ options: [
104
+ { value: 'TOP', label: label('Top', 'Сверху', 'Yuqorida') },
105
+ { value: 'MIDDLE', label: label('Middle', 'По центру', "O'rtada") },
106
+ { value: 'BOTTOM', label: label('Bottom', 'Снизу', 'Pastda') },
107
+ ],
108
+ },
109
+ {
110
+ id: 'overlayOpacity',
111
+ type: 'RANGE',
112
+ label: label('Image overlay', 'Затемнение изображения', 'Rasm qoraytirilishi'),
113
+ info: label(
114
+ 'Darkens the image under the text, in %.',
115
+ 'Затемняет изображение под текстом, в %.',
116
+ 'Matn ostidagi rasmni qoraytiradi, %.',
117
+ ),
118
+ min: 0,
119
+ max: 80,
120
+ step: 10,
121
+ default: 40,
122
+ },
123
+ {
124
+ id: 'height',
125
+ type: 'SELECT',
126
+ label: label('Height', 'Высота', 'Balandlik'),
127
+ default: 'MEDIUM',
128
+ options: [
129
+ { value: 'ADAPT', label: label('As the image', 'Как у изображения', 'Rasmdagidek') },
130
+ { value: 'SMALL', label: label('Small', 'Низкий', 'Past') },
131
+ { value: 'MEDIUM', label: label('Medium', 'Средний', "O'rta") },
132
+ { value: 'LARGE', label: label('Large', 'Высокий', 'Baland') },
133
+ ],
134
+ },
72
135
  ],
73
136
  presets: [{ name: label('Hero', 'Главный баннер', 'Asosiy banner') }],
74
137
  });
75
138
 
76
- /** The page's opening: the merchant's heading over their image, the shop's name when left empty. */
139
+ /**
140
+ * The page's opening (Dawn's image banner): the merchant's image full width with the heading,
141
+ * text and button over it — or below it, for a banner that already carries its own text, and on
142
+ * phones by default. The shop's name and description stand in for an empty heading and text.
143
+ */
77
144
  export async function Hero({ settings }: SectionProps<typeof hero>) {
78
145
  const { data: shop } = await api.shop();
79
146
  const heading = settings.heading || shop.name;
80
147
  const subheading = settings.subheading || shop.description;
148
+ const classes = [
149
+ 'hero',
150
+ `hero--${settings.alignment.toLowerCase()}`,
151
+ `hero--${settings.contentPosition.toLowerCase()}`,
152
+ `hero--${settings.height.toLowerCase()}`,
153
+ settings.image ? `hero--${settings.layout.toLowerCase()}` : 'hero--no-image',
154
+ settings.stackOnMobile && 'hero--stack-mobile',
155
+ ].filter(Boolean);
81
156
  return (
82
- <section className={`hero hero-${settings.alignment.toLowerCase()}`}>
83
- {settings.image && <Image data={settings.image} alt={settings.imageAlt} loading="eager" />}
84
- <div className="hero-text stack">
85
- <h1>{heading}</h1>
86
- {subheading && <p>{subheading}</p>}
157
+ <section
158
+ className={classes.join(' ')}
159
+ style={{ '--hero-overlay': settings.overlayOpacity / 100 } as CSSProperties}
160
+ >
161
+ {settings.image && (
162
+ <div className="hero__media">
163
+ <Image
164
+ data={settings.image}
165
+ alt={settings.imageAlt}
166
+ loading="eager"
167
+ fetchPriority="high"
168
+ />
169
+ </div>
170
+ )}
171
+ <div className="hero__content">
172
+ <h1 className="hero__heading">{heading}</h1>
173
+ {subheading && <p className="hero__text">{subheading}</p>}
87
174
  {settings.buttonLabel && settings.buttonLink && (
88
- <Link href={settings.buttonLink} className="button primary">
175
+ <Link href={settings.buttonLink} className="button button--primary button--lg">
89
176
  {settings.buttonLabel}
90
177
  </Link>
91
178
  )}
@@ -41,6 +41,18 @@ export const imageWithText = defineSection({
41
41
  { value: 'IMAGE_RIGHT', label: label('Right', 'Справа', "O'ngda") },
42
42
  ],
43
43
  },
44
+ {
45
+ id: 'imageRatio',
46
+ type: 'SELECT',
47
+ label: label('Image shape', 'Форма изображения', 'Rasm shakli'),
48
+ default: 'ADAPT',
49
+ options: [
50
+ { value: 'ADAPT', label: label('As the image', 'Как у изображения', 'Rasmdagidek') },
51
+ { value: 'PORTRAIT', label: label('Portrait', 'Вертикальная', 'Vertikal') },
52
+ { value: 'SQUARE', label: label('Square', 'Квадрат', 'Kvadrat') },
53
+ { value: 'LANDSCAPE', label: label('Landscape', 'Горизонтальная', 'Gorizontal') },
54
+ ],
55
+ },
44
56
  ],
45
57
  presets: [{ name: label('Image with text', 'Изображение с текстом', 'Rasm va matn') }],
46
58
  });
@@ -52,10 +64,14 @@ export function ImageWithText({ settings }: SectionProps<typeof imageWithText>)
52
64
  }
53
65
  return (
54
66
  <section
55
- className={`image-with-text ${settings.layout === 'IMAGE_RIGHT' ? 'image-right' : ''}`}
67
+ className={`image-with-text image-with-text--${settings.imageRatio.toLowerCase()} ${settings.layout === 'IMAGE_RIGHT' ? 'image-with-text--image-right' : ''}`}
56
68
  >
57
- {settings.image && <Image data={settings.image} alt={settings.imageAlt} />}
58
- <div className="stack">
69
+ {settings.image && (
70
+ <div className="image-with-text__media">
71
+ <Image data={settings.image} alt={settings.imageAlt} />
72
+ </div>
73
+ )}
74
+ <div className="image-with-text__text">
59
75
  {settings.heading && <h2>{settings.heading}</h2>}
60
76
  {settings.body && (
61
77
  <div className="prose" dangerouslySetInnerHTML={{ __html: settings.body }} />
@@ -0,0 +1,113 @@
1
+ import { Image } from '@magicstoreai/hydrogen';
2
+ import { defineBlock, defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
3
+ import Link from 'next/link';
4
+ import type { CSSProperties } from 'react';
5
+
6
+ import { label } from '../text';
7
+
8
+ export const columnBlock = defineBlock({
9
+ type: 'column',
10
+ name: label('Column', 'Колонка', 'Ustun'),
11
+ settings: [
12
+ { id: 'image', type: 'IMAGE', label: label('Image', 'Изображение', 'Rasm') },
13
+ {
14
+ id: 'imageAlt',
15
+ type: 'TEXT',
16
+ label: label('Image description', 'Описание изображения', 'Rasm tavsifi'),
17
+ default: '',
18
+ },
19
+ {
20
+ id: 'heading',
21
+ type: 'TEXT',
22
+ label: label('Heading', 'Заголовок', 'Sarlavha'),
23
+ maxLength: 120,
24
+ default: '',
25
+ },
26
+ { id: 'text', type: 'RICHTEXT', label: label('Text', 'Текст', 'Matn'), default: '' },
27
+ {
28
+ id: 'linkLabel',
29
+ type: 'TEXT',
30
+ label: label('Link text', 'Текст ссылки', 'Havola matni'),
31
+ maxLength: 40,
32
+ default: '',
33
+ },
34
+ { id: 'link', type: 'URL', label: label('Link', 'Ссылка', 'Havola') },
35
+ ],
36
+ });
37
+
38
+ export const multicolumn = defineSection({
39
+ type: 'multicolumn',
40
+ name: label('Multicolumn', 'Колонки', 'Ustunlar'),
41
+ icon: 'TEXT',
42
+ category: 'TEXT',
43
+ maxBlocks: 8,
44
+ settings: [
45
+ { id: 'heading', type: 'TEXT', label: label('Heading', 'Заголовок', 'Sarlavha'), default: '' },
46
+ {
47
+ id: 'columns',
48
+ type: 'RANGE',
49
+ label: label('Columns on desktop', 'Колонок на компьютере', 'Kompyuterda ustunlar'),
50
+ min: 2,
51
+ max: 4,
52
+ step: 1,
53
+ default: 3,
54
+ },
55
+ {
56
+ id: 'alignment',
57
+ type: 'SELECT',
58
+ label: label('Alignment', 'Выравнивание', 'Tekislash'),
59
+ default: 'LEFT',
60
+ options: [
61
+ { value: 'LEFT', label: label('Left', 'Слева', 'Chapda') },
62
+ { value: 'CENTER', label: label('Center', 'По центру', 'Markazda') },
63
+ ],
64
+ },
65
+ ],
66
+ blocks: [columnBlock],
67
+ presets: [
68
+ {
69
+ name: label('Multicolumn', 'Колонки', 'Ustunlar'),
70
+ blocks: [{ type: 'column' }, { type: 'column' }, { type: 'column' }],
71
+ },
72
+ ],
73
+ });
74
+
75
+ /**
76
+ * Columns of the merchant's image, heading, text and link (delivery, returns, guarantees); a row
77
+ * that scrolls sideways on phones. A column with neither heading nor text is left out.
78
+ */
79
+ export function Multicolumn({ settings, blocks }: SectionProps<typeof multicolumn>) {
80
+ const columns = blocks.filter((block) => block.settings.heading || block.settings.text);
81
+ if (columns.length === 0) {
82
+ return null;
83
+ }
84
+ return (
85
+ <section className={`multicolumn multicolumn--${settings.alignment.toLowerCase()}`}>
86
+ {settings.heading && (
87
+ <div className="section-header">
88
+ <h2>{settings.heading}</h2>
89
+ </div>
90
+ )}
91
+ <ul className="multicolumn__list" style={{ '--columns': settings.columns } as CSSProperties}>
92
+ {columns.map(({ id, settings: column }) => (
93
+ <li key={id} className="multicolumn__item">
94
+ {column.image && (
95
+ <div className="multicolumn__media">
96
+ <Image data={column.image} alt={column.imageAlt} />
97
+ </div>
98
+ )}
99
+ {column.heading && <h3 className="multicolumn__heading">{column.heading}</h3>}
100
+ {column.text && (
101
+ <div className="prose" dangerouslySetInnerHTML={{ __html: column.text }} />
102
+ )}
103
+ {column.linkLabel && column.link && (
104
+ <Link href={column.link} className="multicolumn__link">
105
+ {column.linkLabel}
106
+ </Link>
107
+ )}
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ </section>
112
+ );
113
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Schema } from '@magicstoreai/storefront-client';
2
2
  import { defineSection, type SectionProps } from '@magicstoreai/hydrogen/theme';
3
3
 
4
+ import { Breadcrumbs } from '@/components/ui/breadcrumbs';
4
5
  import { locale } from '@/lib/api';
5
6
  import { messages } from '@/lib/i18n';
6
7
 
@@ -32,13 +33,18 @@ export function PageContent({
32
33
  page,
33
34
  settings,
34
35
  }: SectionProps<typeof pageContent> & { page: Schema<'PageDetail'> }) {
36
+ const t = messages(locale);
35
37
  return (
36
- <article className="stack">
37
- {settings.showTitle && <h1>{page.title}</h1>}
38
+ <article className="content-page">
39
+ <Breadcrumbs
40
+ items={[{ name: t.home, href: '/' }, { name: page.title }]}
41
+ label={t.breadcrumbs}
42
+ />
43
+ {settings.showTitle && <h1 className="content-page__title">{page.title}</h1>}
38
44
  {page.body ? (
39
- <div dangerouslySetInnerHTML={{ __html: page.body }} />
45
+ <div className="prose" dangerouslySetInnerHTML={{ __html: page.body }} />
40
46
  ) : (
41
- <p className="muted">{messages(locale).nothingHere}</p>
47
+ <p className="muted">{t.nothingHere}</p>
42
48
  )}
43
49
  </article>
44
50
  );
@@ -54,6 +54,26 @@ export const productShelf = defineSection({
54
54
  step: 1,
55
55
  default: 8,
56
56
  },
57
+ {
58
+ id: 'layout',
59
+ type: 'SELECT',
60
+ label: label('Layout', 'Вид', "Ko'rinish"),
61
+ default: 'GRID',
62
+ options: [
63
+ { value: 'GRID', label: label('Grid', 'Сетка', 'Panjara') },
64
+ { value: 'SLIDER', label: label('Slider', 'Карусель', 'Karusel') },
65
+ ],
66
+ },
67
+ {
68
+ id: 'showViewAll',
69
+ type: 'CHECKBOX',
70
+ label: label(
71
+ 'Link to the collection (products from a collection)',
72
+ 'Ссылка на коллекцию (для товаров из коллекции)',
73
+ "To'plamga havola (to'plam mahsulotlari uchun)",
74
+ ),
75
+ default: true,
76
+ },
57
77
  ],
58
78
  presets: [{ name: label('Product shelf', 'Полка товаров', 'Mahsulotlar qatori') }],
59
79
  });
@@ -62,18 +82,19 @@ export const productShelf = defineSection({
62
82
  export async function ProductShelf({ settings }: SectionProps<typeof productShelf>) {
63
83
  const t = messages(locale);
64
84
  const perPage = settings.limit;
85
+ const slider = settings.layout === 'SLIDER';
65
86
  switch (settings.source) {
66
87
  case 'NEW_ARRIVALS': {
67
88
  const { data } = await api.productsIndex({ query: { sort: '-createdAt', perPage } });
68
- return <Shelf title={settings.heading || t.newArrivals} products={data} />;
89
+ return <Shelf title={settings.heading || t.newArrivals} products={data} slider={slider} />;
69
90
  }
70
91
  case 'ON_SALE': {
71
92
  const { data } = await api.productsIndex({ query: { 'filter[onSale]': 'true', perPage } });
72
- return <Shelf title={settings.heading || t.onSale} products={data} />;
93
+ return <Shelf title={settings.heading || t.onSale} products={data} slider={slider} />;
73
94
  }
74
95
  case 'BESTSELLERS': {
75
96
  const { data } = await api.productsIndex({ query: { sort: '-bestSellers', perPage } });
76
- return <Shelf title={settings.heading || t.bestsellers} products={data} />;
97
+ return <Shelf title={settings.heading || t.bestsellers} products={data} slider={slider} />;
77
98
  }
78
99
  case 'COLLECTION': {
79
100
  const { collection } = settings;
@@ -87,8 +108,9 @@ export async function ProductShelf({ settings }: SectionProps<typeof productShel
87
108
  return (
88
109
  <Shelf
89
110
  title={settings.heading || collection.title}
90
- href={`/collections/${collection.handle}`}
111
+ href={settings.showViewAll ? `/collections/${collection.handle}` : undefined}
91
112
  products={data}
113
+ slider={slider}
92
114
  />
93
115
  );
94
116
  }