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,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
+ }
@@ -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
  }
@@ -20,6 +20,16 @@ export const richText = defineSection({
20
20
  { value: 'WIDE', label: label('Wide', 'Широкая', 'Keng') },
21
21
  ],
22
22
  },
23
+ {
24
+ id: 'alignment',
25
+ type: 'SELECT',
26
+ label: label('Alignment', 'Выравнивание', 'Tekislash'),
27
+ default: 'CENTER',
28
+ options: [
29
+ { value: 'LEFT', label: label('Left', 'Слева', 'Chapda') },
30
+ { value: 'CENTER', label: label('Center', 'По центру', 'Markazda') },
31
+ ],
32
+ },
23
33
  ],
24
34
  presets: [{ name: label('Text', 'Текст', 'Matn') }],
25
35
  });
@@ -33,7 +43,9 @@ export function RichText({ settings }: SectionProps<typeof richText>) {
33
43
  return null;
34
44
  }
35
45
  return (
36
- <section className={`stack rich-text rich-text-${settings.width.toLowerCase()}`}>
46
+ <section
47
+ className={`rich-text rich-text--${settings.width.toLowerCase()} rich-text--${settings.alignment.toLowerCase()}`}
48
+ >
37
49
  {settings.heading && <h2>{settings.heading}</h2>}
38
50
  {settings.content && (
39
51
  <div className="prose" dangerouslySetInnerHTML={{ __html: settings.content }} />