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
@@ -7,6 +7,9 @@ import type { Schema } from '@magicstoreai/storefront-client';
7
7
  */
8
8
 
9
9
  type OrderStatus = Schema<'Order'>['status'];
10
+ type LineIssue = Schema<'CartLine'>['issues'][number];
11
+ type PaymentStatus = NonNullable<Schema<'Order'>['payment']>['status'];
12
+ type DiscountReason = NonNullable<Schema<'CartDiscountCode'>['reasonCode']>;
10
13
 
11
14
  function plural(
12
15
  locale: string,
@@ -59,11 +62,117 @@ const en = {
59
62
  leaveReview: 'Leave a review',
60
63
  shopNow: 'Shop now',
61
64
  ratedOutOf: (rating: number) => `Rated ${rating} out of 5`,
65
+ // UI primitives
66
+ // Header
67
+ menu: 'Menu',
68
+ back: 'Back',
69
+ skipToContent: 'Skip to content',
70
+ catalog: 'Catalog',
71
+ wishlist: 'Wishlist',
72
+ cartCount: (count: number) =>
73
+ `Cart, ${count} ${plural('en', count, { one: 'item', other: 'items' })}`,
74
+ submenu: (title: string) => `${title}: more`,
75
+ // Footer
76
+ information: 'Information',
77
+ contacts: 'Contacts',
78
+ email: 'Email',
79
+ openingHours: 'Opening hours',
80
+ allContacts: 'All contacts and addresses',
81
+ headOffice: 'Head office',
82
+ branches: 'Branches',
83
+ showOnMap: 'Show on map',
84
+ landmark: 'Landmark',
85
+ closed: 'Closed',
86
+ requisites: 'Company details',
87
+ contactsEmpty: 'The shop has not added its contacts yet.',
88
+ socialMedia: 'Social media',
89
+ // Cart drawer
90
+ viewCart: 'View cart',
91
+ deliveryAtCheckout: 'Delivery is calculated at checkout.',
92
+ cartEmptyHint: 'Add something from the catalog, it will wait for you here.',
93
+ lineIssues: {
94
+ INVALID_QUANTITY: 'This quantity cannot be ordered.',
95
+ NOT_FOUND: 'This product is no longer sold.',
96
+ UNPUBLISHED: 'This product is no longer sold.',
97
+ VARIANT_MISMATCH: 'This option is no longer available.',
98
+ INSUFFICIENT_STOCK: 'Not enough in stock for this quantity.',
99
+ BUNDLE_UNAVAILABLE: 'This set is no longer available.',
100
+ } satisfies Record<LineIssue, string>,
101
+ // Predictive search
102
+ products: 'Products',
103
+ collections: 'Collections',
104
+ suggestions: 'Suggestions',
105
+ popularSearches: 'Popular searches',
106
+ searchFor: (query: string) => `Search for "${query}"`,
107
+ noSuggestions: (query: string) => `Nothing found for "${query}".`,
108
+ quickAdd: (title: string) => `Add to cart: ${title}`,
109
+ // Catalog
110
+ filters: 'Filters',
111
+ sortBy: 'Sort by',
112
+ sortOptions: {
113
+ default: 'Featured',
114
+ '-createdAt': 'Newest',
115
+ price: 'Price, low to high',
116
+ '-price': 'Price, high to low',
117
+ '-bestSellers': 'Best selling',
118
+ '-rating': 'Top rated',
119
+ },
120
+ priceFrom: 'From',
121
+ priceTo: 'To',
122
+ clearAll: 'Clear all',
123
+ removeFilter: (label: string) => `Remove filter: ${label}`,
124
+ showResults: 'Show results',
125
+ productsCount: (count: number) =>
126
+ `${count} ${plural('en', count, { one: 'product', other: 'products' })}`,
127
+ noMatches: 'No products match these filters',
128
+ noMatchesHint: 'Try removing a filter or two.',
129
+ collectionEmpty: 'Nothing here yet',
130
+ collectionEmptyHint:
131
+ 'Products will appear here soon. Meanwhile, take a look at the rest of the catalog.',
132
+ faq: 'Questions and answers',
133
+ // Product page
134
+ description: 'Description',
135
+ inStock: 'In stock',
136
+ lowStock: (count: number) => `Only ${count} left`,
137
+ selectOption: (name: string) => `Choose ${name}`,
138
+ share: 'Share',
139
+ linkCopied: 'Link copied',
140
+ imageOf: (index: number, total: number) => `Image ${index} of ${total}`,
141
+ video: 'Video',
142
+ previousImage: 'Previous image',
143
+ nextImage: 'Next image',
144
+ reviews: 'Reviews',
145
+ noReviewsForRating: 'No reviews with this rating.',
146
+ verifiedBuyer: 'Verified buyer',
147
+ pros: 'Pros',
148
+ cons: 'Cons',
149
+ shopReply: 'Reply from the shop',
150
+ showMoreReviews: 'Show more reviews',
151
+ allRatings: 'All',
152
+ starsFilter: (stars: number) =>
153
+ `${stars} ${plural('en', stars, { one: 'star', other: 'stars' })}`,
154
+ relatedProducts: 'You may also like',
155
+ pairsWellWith: 'Pairs well with',
156
+ preOrderHint: 'Leave your contact and the shop will get in touch when it arrives.',
157
+ preOrderSent: 'Pre-order sent. The shop will contact you.',
158
+ sendPreOrder: 'Send pre-order',
159
+ // Wishlist
160
+ wishlistEmpty: 'Your wishlist is empty',
161
+ wishlistEmptyHint: 'Tap the heart on a product to keep it here.',
162
+ wishlistGuestHint: 'Saved on this device only. Sign in to keep your wishlist everywhere.',
163
+ removeFromWishlist: (title: string) => `Remove ${title} from wishlist`,
164
+ close: 'Close',
165
+ breadcrumbs: 'Breadcrumbs',
166
+ decreaseQuantity: 'Decrease quantity',
167
+ increaseQuantity: 'Increase quantity',
168
+ ratingSummary: (average: number, count: number) =>
169
+ `Rated ${average} out of 5, ${count} ${plural('en', count, { one: 'review', other: 'reviews' })}`,
62
170
  // Blog
63
171
  blog: 'Blog',
64
172
  allArticles: 'All articles',
65
173
  featured: "Editor's picks",
66
174
  readingTime: (minutes: number) => `${minutes} min read`,
175
+ tableOfContents: 'Contents',
67
176
  relatedArticles: 'Read also',
68
177
  productsInArticle: 'Products in this article',
69
178
  wasHelpful: 'Was this article helpful?',
@@ -83,6 +192,7 @@ const en = {
83
192
  saved: 'Saved',
84
193
  // Cart
85
194
  cartEmpty: 'Your cart is empty',
195
+ loading: 'Loading…',
86
196
  loadingCart: 'Loading your cart…',
87
197
  quantity: 'Quantity',
88
198
  remove: 'Remove',
@@ -91,6 +201,24 @@ const en = {
91
201
  apply: 'Apply',
92
202
  codeApplied: 'applied',
93
203
  codeNotApplied: 'not applied',
204
+ discountReasons: {
205
+ NOT_FOUND: 'There is no such code.',
206
+ EXPIRED: 'This code has expired.',
207
+ NOT_AVAILABLE: 'This code is not available now.',
208
+ USAGE_LIMIT_REACHED: 'This code has been used up.',
209
+ ALREADY_USED: 'You have already used this code.',
210
+ CUSTOMER_NOT_ELIGIBLE: 'This code is not for your account.',
211
+ SEGMENT_NOT_ELIGIBLE: 'This code is not for your account.',
212
+ MINIMUM_SPEND_NOT_MET: 'Your order is below the minimum for this code.',
213
+ MAXIMUM_SPEND_EXCEEDED: 'Your order is above the maximum for this code.',
214
+ PRODUCTS_NOT_IN_CART: 'This code is for products not in your cart.',
215
+ } satisfies Record<DiscountReason, string>,
216
+ removeCode: (code: string) => `Remove code ${code}`,
217
+ welcomeDiscount: 'Welcome discount',
218
+ pointsDiscount: 'Paid with points',
219
+ orderNote: 'Note to the shop',
220
+ noteSaved: 'Note saved',
221
+ summary: 'Order summary',
94
222
  checkout: 'Checkout',
95
223
  // Checkout
96
224
  checkoutTitle: 'Checkout',
@@ -115,6 +243,12 @@ const en = {
115
243
  thankYou: 'Thank you!',
116
244
  order: 'Order',
117
245
  payNow: 'Pay now',
246
+ checkoutSteps: 'Checkout steps',
247
+ stepDone: 'done',
248
+ showOrderSummary: 'Show order summary',
249
+ etaMinutes: (minutes: number) => `about ${minutes} min`,
250
+ orderNumberHint: 'Keep this number: the shop refers to your order by it.',
251
+ backToCart: 'Back to cart',
118
252
  // Account
119
253
  signIn: 'Sign in',
120
254
  sendCode: 'Send code',
@@ -133,6 +267,41 @@ const en = {
133
267
  orders: 'Orders',
134
268
  loadingOrders: 'Loading orders…',
135
269
  noOrders: 'No orders yet.',
270
+ accountNav: 'Account',
271
+ overview: 'Overview',
272
+ addresses: 'Addresses',
273
+ profile: 'Profile',
274
+ orderNumber: (number: string) => `Order ${number}`,
275
+ placedOn: (date: string) => `Placed ${date}`,
276
+ itemsCount: (count: number) => `${count} ${plural('en', count, { one: 'item', other: 'items' })}`,
277
+ backToOrders: 'All orders',
278
+ orderAgain: 'Order again',
279
+ cancelOrder: 'Cancel order',
280
+ cancelOrderConfirm: 'Cancel this order? The shop will not prepare it.',
281
+ keepOrder: 'Keep it',
282
+ orderCancelledNote: 'This order is cancelled.',
283
+ paymentStatus: {
284
+ PENDING: 'Awaiting payment',
285
+ PAID: 'Paid',
286
+ FAILED: 'Not paid',
287
+ NOT_REQUIRED: 'Paid outside the store',
288
+ } satisfies Record<PaymentStatus, string>,
289
+ deliveryAddress: 'Delivery address',
290
+ pickupPoint: 'Pickup point',
291
+ addAddress: 'Add address',
292
+ editAddress: 'Edit',
293
+ deleteAddress: (address: string) => `Delete ${address}`,
294
+ defaultAddress: 'Default',
295
+ makeDefault: 'Make default',
296
+ noAddresses: 'No saved addresses yet.',
297
+ address2: 'Apartment, entrance, floor',
298
+ cancel: 'Cancel',
299
+ saveChanges: 'Save',
300
+ changesSaved: 'Saved',
301
+ gender: 'Gender',
302
+ genderOptions: { MALE: 'Male', FEMALE: 'Female', none: 'Not specified' },
303
+ language: 'Language',
304
+ phoneHint: 'You sign in with this number.',
136
305
  savedProducts: 'Saved products',
137
306
  orderStatus: {
138
307
  NEW: 'New',
@@ -186,10 +355,109 @@ const ru: Messages = {
186
355
  leaveReview: 'Оставить отзыв',
187
356
  shopNow: 'Перейти в каталог',
188
357
  ratedOutOf: (rating: number) => `Оценка ${rating} из 5`,
358
+ menu: 'Меню',
359
+ back: 'Назад',
360
+ skipToContent: 'Перейти к содержимому',
361
+ catalog: 'Каталог',
362
+ wishlist: 'Избранное',
363
+ cartCount: (count) =>
364
+ `Корзина, ${count} ${plural('ru', count, { one: 'товар', few: 'товара', other: 'товаров' })}`,
365
+ submenu: (title) => `${title}: подразделы`,
366
+ information: 'Информация',
367
+ contacts: 'Контакты',
368
+ email: 'Эл. почта',
369
+ openingHours: 'Часы работы',
370
+ allContacts: 'Все контакты и адреса',
371
+ headOffice: 'Главный офис',
372
+ branches: 'Филиалы',
373
+ showOnMap: 'Показать на карте',
374
+ landmark: 'Ориентир',
375
+ closed: 'Выходной',
376
+ requisites: 'Реквизиты',
377
+ contactsEmpty: 'Магазин пока не добавил контакты.',
378
+ socialMedia: 'Мы в соцсетях',
379
+ viewCart: 'Перейти в корзину',
380
+ deliveryAtCheckout: 'Доставка рассчитывается при оформлении.',
381
+ cartEmptyHint: 'Добавьте что-нибудь из каталога, товары подождут вас здесь.',
382
+ lineIssues: {
383
+ INVALID_QUANTITY: 'Такое количество заказать нельзя.',
384
+ NOT_FOUND: 'Этот товар больше не продаётся.',
385
+ UNPUBLISHED: 'Этот товар больше не продаётся.',
386
+ VARIANT_MISMATCH: 'Этот вариант больше недоступен.',
387
+ INSUFFICIENT_STOCK: 'В наличии меньше, чем выбрано.',
388
+ BUNDLE_UNAVAILABLE: 'Этот набор больше недоступен.',
389
+ },
390
+ products: 'Товары',
391
+ collections: 'Коллекции',
392
+ suggestions: 'Подсказки',
393
+ popularSearches: 'Часто ищут',
394
+ searchFor: (query) => `Искать «${query}»`,
395
+ noSuggestions: (query) => `По запросу «${query}» ничего не нашлось.`,
396
+ quickAdd: (title) => `В корзину: ${title}`,
397
+ filters: 'Фильтры',
398
+ sortBy: 'Сортировка',
399
+ sortOptions: {
400
+ default: 'Рекомендуемые',
401
+ '-createdAt': 'Сначала новые',
402
+ price: 'Сначала дешевле',
403
+ '-price': 'Сначала дороже',
404
+ '-bestSellers': 'Хиты продаж',
405
+ '-rating': 'С высоким рейтингом',
406
+ },
407
+ priceFrom: 'От',
408
+ priceTo: 'До',
409
+ clearAll: 'Сбросить всё',
410
+ removeFilter: (label) => `Убрать фильтр: ${label}`,
411
+ showResults: 'Показать товары',
412
+ productsCount: (count) =>
413
+ `${count} ${plural('ru', count, { one: 'товар', few: 'товара', other: 'товаров' })}`,
414
+ noMatches: 'Нет товаров с такими фильтрами',
415
+ noMatchesHint: 'Попробуйте убрать один-два фильтра.',
416
+ collectionEmpty: 'Здесь пока пусто',
417
+ collectionEmptyHint: 'Товары скоро появятся. А пока загляните в остальной каталог.',
418
+ faq: 'Вопросы и ответы',
419
+ description: 'Описание',
420
+ inStock: 'В наличии',
421
+ lowStock: (count) =>
422
+ `${plural('ru', count, { one: 'Остался', few: 'Осталось', other: 'Осталось' })} ${count} шт.`,
423
+ selectOption: (name) => `Выберите: ${name}`,
424
+ share: 'Поделиться',
425
+ linkCopied: 'Ссылка скопирована',
426
+ imageOf: (index, total) => `Фото ${index} из ${total}`,
427
+ video: 'Видео',
428
+ previousImage: 'Предыдущее фото',
429
+ nextImage: 'Следующее фото',
430
+ reviews: 'Отзывы',
431
+ noReviewsForRating: 'Отзывов с такой оценкой нет.',
432
+ verifiedBuyer: 'Подтверждённая покупка',
433
+ pros: 'Достоинства',
434
+ cons: 'Недостатки',
435
+ shopReply: 'Ответ магазина',
436
+ showMoreReviews: 'Показать ещё отзывы',
437
+ allRatings: 'Все',
438
+ starsFilter: (stars) =>
439
+ `${stars} ${plural('ru', stars, { one: 'звезда', few: 'звезды', other: 'звёзд' })}`,
440
+ relatedProducts: 'Вам может понравиться',
441
+ pairsWellWith: 'С этим товаром покупают',
442
+ preOrderHint: 'Оставьте контакт — магазин свяжется с вами, когда товар поступит.',
443
+ preOrderSent: 'Предзаказ отправлен. Магазин свяжется с вами.',
444
+ sendPreOrder: 'Оформить предзаказ',
445
+ wishlistEmpty: 'В избранном пока пусто',
446
+ wishlistEmptyHint: 'Нажмите на сердечко у товара, чтобы сохранить его здесь.',
447
+ wishlistGuestHint:
448
+ 'Избранное сохранено только на этом устройстве. Войдите, чтобы оно было с вами везде.',
449
+ removeFromWishlist: (title) => `Убрать «${title}» из избранного`,
450
+ close: 'Закрыть',
451
+ breadcrumbs: 'Навигационная цепочка',
452
+ decreaseQuantity: 'Уменьшить количество',
453
+ increaseQuantity: 'Увеличить количество',
454
+ ratingSummary: (average, count) =>
455
+ `Оценка ${average} из 5, ${count} ${plural('ru', count, { one: 'отзыв', few: 'отзыва', other: 'отзывов' })}`,
189
456
  blog: 'Блог',
190
457
  allArticles: 'Все статьи',
191
458
  featured: 'Выбор редакции',
192
459
  readingTime: (minutes) => `${minutes} мин чтения`,
460
+ tableOfContents: 'Содержание',
193
461
  relatedArticles: 'Читайте также',
194
462
  productsInArticle: 'Товары из статьи',
195
463
  wasHelpful: 'Статья была полезной?',
@@ -207,6 +475,7 @@ const ru: Messages = {
207
475
  save: 'В избранное',
208
476
  saved: 'В избранном',
209
477
  cartEmpty: 'Корзина пуста',
478
+ loading: 'Загрузка…',
210
479
  loadingCart: 'Загружаем корзину…',
211
480
  quantity: 'Количество',
212
481
  remove: 'Удалить',
@@ -215,6 +484,24 @@ const ru: Messages = {
215
484
  apply: 'Применить',
216
485
  codeApplied: 'применён',
217
486
  codeNotApplied: 'не применён',
487
+ discountReasons: {
488
+ NOT_FOUND: 'Такого промокода нет.',
489
+ EXPIRED: 'Срок действия промокода истёк.',
490
+ NOT_AVAILABLE: 'Промокод сейчас недоступен.',
491
+ USAGE_LIMIT_REACHED: 'Промокод больше не действует: лимит исчерпан.',
492
+ ALREADY_USED: 'Вы уже использовали этот промокод.',
493
+ CUSTOMER_NOT_ELIGIBLE: 'Промокод не подходит для вашего аккаунта.',
494
+ SEGMENT_NOT_ELIGIBLE: 'Промокод не подходит для вашего аккаунта.',
495
+ MINIMUM_SPEND_NOT_MET: 'Сумма заказа меньше минимальной для промокода.',
496
+ MAXIMUM_SPEND_EXCEEDED: 'Сумма заказа больше максимальной для промокода.',
497
+ PRODUCTS_NOT_IN_CART: 'Промокод действует на товары, которых нет в корзине.',
498
+ },
499
+ removeCode: (code) => `Убрать промокод ${code}`,
500
+ welcomeDiscount: 'Приветственная скидка',
501
+ pointsDiscount: 'Оплачено баллами',
502
+ orderNote: 'Комментарий к заказу',
503
+ noteSaved: 'Комментарий сохранён',
504
+ summary: 'Ваш заказ',
218
505
  checkout: 'Оформить заказ',
219
506
  checkoutTitle: 'Оформление заказа',
220
507
  preparingCheckout: 'Готовим оформление…',
@@ -238,6 +525,12 @@ const ru: Messages = {
238
525
  thankYou: 'Спасибо!',
239
526
  order: 'Заказ',
240
527
  payNow: 'Оплатить',
528
+ checkoutSteps: 'Шаги оформления',
529
+ stepDone: 'готово',
530
+ showOrderSummary: 'Показать заказ',
531
+ etaMinutes: (minutes) => `около ${minutes} мин`,
532
+ orderNumberHint: 'Сохраните этот номер: по нему магазин найдёт ваш заказ.',
533
+ backToCart: 'Вернуться в корзину',
241
534
  signIn: 'Войти',
242
535
  sendCode: 'Получить код',
243
536
  smsCode: 'Код из SMS',
@@ -253,6 +546,42 @@ const ru: Messages = {
253
546
  orders: 'Заказы',
254
547
  loadingOrders: 'Загружаем заказы…',
255
548
  noOrders: 'Заказов пока нет.',
549
+ accountNav: 'Личный кабинет',
550
+ overview: 'Обзор',
551
+ addresses: 'Адреса',
552
+ profile: 'Профиль',
553
+ orderNumber: (number) => `Заказ ${number}`,
554
+ placedOn: (date) => `Оформлен ${date}`,
555
+ itemsCount: (count) =>
556
+ `${count} ${plural('ru', count, { one: 'товар', few: 'товара', other: 'товаров' })}`,
557
+ backToOrders: 'Все заказы',
558
+ orderAgain: 'Повторить заказ',
559
+ cancelOrder: 'Отменить заказ',
560
+ cancelOrderConfirm: 'Отменить этот заказ? Магазин не будет его собирать.',
561
+ keepOrder: 'Не отменять',
562
+ orderCancelledNote: 'Этот заказ отменён.',
563
+ paymentStatus: {
564
+ PENDING: 'Ожидает оплаты',
565
+ PAID: 'Оплачен',
566
+ FAILED: 'Не оплачен',
567
+ NOT_REQUIRED: 'Оплачен вне магазина',
568
+ },
569
+ deliveryAddress: 'Адрес доставки',
570
+ pickupPoint: 'Пункт самовывоза',
571
+ addAddress: 'Добавить адрес',
572
+ editAddress: 'Изменить',
573
+ deleteAddress: (address) => `Удалить адрес ${address}`,
574
+ defaultAddress: 'Основной',
575
+ makeDefault: 'Сделать основным',
576
+ noAddresses: 'Сохранённых адресов пока нет.',
577
+ address2: 'Квартира, подъезд, этаж',
578
+ cancel: 'Отмена',
579
+ saveChanges: 'Сохранить',
580
+ changesSaved: 'Сохранено',
581
+ gender: 'Пол',
582
+ genderOptions: { MALE: 'Мужской', FEMALE: 'Женский', none: 'Не указан' },
583
+ language: 'Язык',
584
+ phoneHint: 'По этому номеру вы входите в аккаунт.',
256
585
  savedProducts: 'Избранное',
257
586
  orderStatus: {
258
587
  NEW: 'Новый',
@@ -303,10 +632,105 @@ const uz: Messages = {
303
632
  leaveReview: 'Sharh qoldirish',
304
633
  shopNow: "Katalogga o'tish",
305
634
  ratedOutOf: (rating: number) => `Baho: ${rating} / 5`,
635
+ menu: 'Menyu',
636
+ back: 'Orqaga',
637
+ skipToContent: "Asosiy qismga o'tish",
638
+ catalog: 'Katalog',
639
+ wishlist: 'Saralanganlar',
640
+ cartCount: (count) => `Savat, ${count} ta mahsulot`,
641
+ submenu: (title) => `${title}: bo'limlar`,
642
+ information: "Ma'lumot",
643
+ contacts: 'Kontaktlar',
644
+ email: 'Elektron pochta',
645
+ openingHours: 'Ish vaqti',
646
+ allContacts: 'Barcha kontaktlar va manzillar',
647
+ headOffice: 'Bosh ofis',
648
+ branches: 'Filiallar',
649
+ showOnMap: "Xaritada ko'rsatish",
650
+ landmark: "Mo'ljal",
651
+ closed: 'Dam olish kuni',
652
+ requisites: 'Rekvizitlar',
653
+ contactsEmpty: "Do'kon hali kontaktlarini qo'shmagan.",
654
+ socialMedia: 'Ijtimoiy tarmoqlar',
655
+ viewCart: "Savatga o'tish",
656
+ deliveryAtCheckout: 'Yetkazib berish narxi rasmiylashtirishda hisoblanadi.',
657
+ cartEmptyHint: "Katalogdan biror narsa qo'shing, u shu yerda sizni kutadi.",
658
+ lineIssues: {
659
+ INVALID_QUANTITY: "Bu miqdorda buyurtma berib bo'lmaydi.",
660
+ NOT_FOUND: 'Bu mahsulot endi sotilmaydi.',
661
+ UNPUBLISHED: 'Bu mahsulot endi sotilmaydi.',
662
+ VARIANT_MISMATCH: 'Bu variant endi mavjud emas.',
663
+ INSUFFICIENT_STOCK: 'Omborda tanlangandan kam qolgan.',
664
+ BUNDLE_UNAVAILABLE: "Bu to'plam endi mavjud emas.",
665
+ },
666
+ products: 'Mahsulotlar',
667
+ collections: "To'plamlar",
668
+ suggestions: 'Takliflar',
669
+ popularSearches: "Ko'p qidirilganlar",
670
+ searchFor: (query) => `«${query}» bo'yicha qidirish`,
671
+ noSuggestions: (query) => `«${query}» bo'yicha hech narsa topilmadi.`,
672
+ quickAdd: (title) => `Savatga qo'shish: ${title}`,
673
+ filters: 'Filtrlar',
674
+ sortBy: 'Saralash',
675
+ sortOptions: {
676
+ default: 'Tavsiya etilgan',
677
+ '-createdAt': 'Avval yangilari',
678
+ price: 'Avval arzonlari',
679
+ '-price': 'Avval qimmatlari',
680
+ '-bestSellers': "Ko'p sotilganlar",
681
+ '-rating': 'Yuqori reytingli',
682
+ },
683
+ priceFrom: 'Dan',
684
+ priceTo: 'Gacha',
685
+ clearAll: 'Hammasini tozalash',
686
+ removeFilter: (label) => `Filtrni olib tashlash: ${label}`,
687
+ showResults: "Mahsulotlarni ko'rsatish",
688
+ productsCount: (count) => `${count} ta mahsulot`,
689
+ noMatches: "Bunday filtrlar bilan mahsulot yo'q",
690
+ noMatchesHint: "Bir-ikkita filtrni olib tashlab ko'ring.",
691
+ collectionEmpty: "Hozircha bu yerda hech narsa yo'q",
692
+ collectionEmptyHint:
693
+ "Mahsulotlar tez orada paydo bo'ladi. Hozircha katalogning qolgan qismini ko'ring.",
694
+ faq: 'Savol va javoblar',
695
+ description: 'Tavsif',
696
+ inStock: 'Sotuvda bor',
697
+ lowStock: (count) => `Faqat ${count} ta qoldi`,
698
+ selectOption: (name) => `Tanlang: ${name}`,
699
+ share: 'Ulashish',
700
+ linkCopied: 'Havola nusxalandi',
701
+ imageOf: (index, total) => `Rasm ${index} / ${total}`,
702
+ video: 'Video',
703
+ previousImage: 'Oldingi rasm',
704
+ nextImage: 'Keyingi rasm',
705
+ reviews: 'Sharhlar',
706
+ noReviewsForRating: "Bunday baholi sharhlar yo'q.",
707
+ verifiedBuyer: 'Tasdiqlangan xarid',
708
+ pros: 'Afzalliklari',
709
+ cons: 'Kamchiliklari',
710
+ shopReply: "Do'kon javobi",
711
+ showMoreReviews: 'Yana sharhlar',
712
+ allRatings: 'Barchasi',
713
+ starsFilter: (stars) => `${stars} yulduz`,
714
+ relatedProducts: 'Sizga yoqishi mumkin',
715
+ pairsWellWith: 'Bu mahsulot bilan olishadi',
716
+ preOrderHint: "Kontaktingizni qoldiring — mahsulot kelganda do'kon siz bilan bog'lanadi.",
717
+ preOrderSent: "Oldindan buyurtma yuborildi. Do'kon siz bilan bog'lanadi.",
718
+ sendPreOrder: 'Oldindan buyurtma berish',
719
+ wishlistEmpty: "Sevimlilar hozircha bo'sh",
720
+ wishlistEmptyHint: 'Mahsulotni shu yerda saqlash uchun yurakchani bosing.',
721
+ wishlistGuestHint:
722
+ "Sevimlilar faqat shu qurilmada saqlangan. Hamma joyda ko'rish uchun tizimga kiring.",
723
+ removeFromWishlist: (title) => `«${title}»ni sevimlilardan olib tashlash`,
724
+ close: 'Yopish',
725
+ breadcrumbs: 'Navigatsiya',
726
+ decreaseQuantity: 'Sonini kamaytirish',
727
+ increaseQuantity: 'Sonini oshirish',
728
+ ratingSummary: (average, count) => `Baho: ${average} / 5, ${count} ta sharh`,
306
729
  blog: 'Blog',
307
730
  allArticles: 'Barcha maqolalar',
308
731
  featured: 'Muharrir tanlovi',
309
732
  readingTime: (minutes) => `${minutes} daqiqa o'qish`,
733
+ tableOfContents: 'Mundarija',
310
734
  relatedArticles: "Shuningdek o'qing",
311
735
  productsInArticle: 'Maqoladagi mahsulotlar',
312
736
  wasHelpful: "Maqola foydali bo'ldimi?",
@@ -324,6 +748,7 @@ const uz: Messages = {
324
748
  save: 'Saqlash',
325
749
  saved: 'Saqlangan',
326
750
  cartEmpty: "Savatingiz bo'sh",
751
+ loading: 'Yuklanmoqda…',
327
752
  loadingCart: 'Savat yuklanmoqda…',
328
753
  quantity: 'Soni',
329
754
  remove: "O'chirish",
@@ -332,6 +757,24 @@ const uz: Messages = {
332
757
  apply: "Qo'llash",
333
758
  codeApplied: "qo'llanildi",
334
759
  codeNotApplied: "qo'llanilmadi",
760
+ discountReasons: {
761
+ NOT_FOUND: "Bunday promokod yo'q.",
762
+ EXPIRED: 'Promokod muddati tugagan.',
763
+ NOT_AVAILABLE: 'Promokod hozir mavjud emas.',
764
+ USAGE_LIMIT_REACHED: 'Promokod limiti tugagan.',
765
+ ALREADY_USED: 'Siz bu promokoddan allaqachon foydalangansiz.',
766
+ CUSTOMER_NOT_ELIGIBLE: 'Promokod hisobingiz uchun emas.',
767
+ SEGMENT_NOT_ELIGIBLE: 'Promokod hisobingiz uchun emas.',
768
+ MINIMUM_SPEND_NOT_MET: 'Buyurtma summasi promokod uchun minimaldan kam.',
769
+ MAXIMUM_SPEND_EXCEEDED: "Buyurtma summasi promokod uchun maksimaldan ko'p.",
770
+ PRODUCTS_NOT_IN_CART: "Promokod savatda yo'q mahsulotlar uchun.",
771
+ },
772
+ removeCode: (code) => `${code} promokodini olib tashlash`,
773
+ welcomeDiscount: 'Xush kelibsiz chegirmasi',
774
+ pointsDiscount: "Ballar bilan to'langan",
775
+ orderNote: "Do'konga izoh",
776
+ noteSaved: 'Izoh saqlandi',
777
+ summary: 'Buyurtmangiz',
335
778
  checkout: 'Rasmiylashtirish',
336
779
  checkoutTitle: 'Buyurtmani rasmiylashtirish',
337
780
  preparingCheckout: 'Tayyorlanmoqda…',
@@ -355,6 +798,12 @@ const uz: Messages = {
355
798
  thankYou: 'Rahmat!',
356
799
  order: 'Buyurtma',
357
800
  payNow: "To'lash",
801
+ checkoutSteps: 'Rasmiylashtirish bosqichlari',
802
+ stepDone: 'tayyor',
803
+ showOrderSummary: "Buyurtmani ko'rsatish",
804
+ etaMinutes: (minutes) => `taxminan ${minutes} daqiqa`,
805
+ orderNumberHint: "Bu raqamni saqlang: do'kon buyurtmangizni u orqali topadi.",
806
+ backToCart: 'Savatga qaytish',
358
807
  signIn: 'Kirish',
359
808
  sendCode: 'Kod olish',
360
809
  smsCode: 'SMS dagi kod',
@@ -371,6 +820,41 @@ const uz: Messages = {
371
820
  orders: 'Buyurtmalar',
372
821
  loadingOrders: 'Buyurtmalar yuklanmoqda…',
373
822
  noOrders: "Hozircha buyurtmalar yo'q.",
823
+ accountNav: 'Shaxsiy kabinet',
824
+ overview: 'Umumiy',
825
+ addresses: 'Manzillar',
826
+ profile: 'Profil',
827
+ orderNumber: (number) => `Buyurtma ${number}`,
828
+ placedOn: (date) => `${date} da berilgan`,
829
+ itemsCount: (count) => `${count} ta mahsulot`,
830
+ backToOrders: 'Barcha buyurtmalar',
831
+ orderAgain: 'Qayta buyurtma berish',
832
+ cancelOrder: 'Buyurtmani bekor qilish',
833
+ cancelOrderConfirm: "Buyurtma bekor qilinsinmi? Do'kon uni tayyorlamaydi.",
834
+ keepOrder: 'Bekor qilmaslik',
835
+ orderCancelledNote: 'Bu buyurtma bekor qilingan.',
836
+ paymentStatus: {
837
+ PENDING: "To'lov kutilmoqda",
838
+ PAID: "To'langan",
839
+ FAILED: "To'lanmagan",
840
+ NOT_REQUIRED: "Do'kondan tashqarida to'langan",
841
+ },
842
+ deliveryAddress: 'Yetkazib berish manzili',
843
+ pickupPoint: 'Olib ketish punkti',
844
+ addAddress: "Manzil qo'shish",
845
+ editAddress: "O'zgartirish",
846
+ deleteAddress: (address) => `${address} manzilini o'chirish`,
847
+ defaultAddress: 'Asosiy',
848
+ makeDefault: 'Asosiy qilish',
849
+ noAddresses: "Saqlangan manzillar hozircha yo'q.",
850
+ address2: 'Kvartira, kirish, qavat',
851
+ cancel: 'Bekor qilish',
852
+ saveChanges: 'Saqlash',
853
+ changesSaved: 'Saqlandi',
854
+ gender: 'Jins',
855
+ genderOptions: { MALE: 'Erkak', FEMALE: 'Ayol', none: "Ko'rsatilmagan" },
856
+ language: 'Til',
857
+ phoneHint: 'Siz shu raqam bilan kirasiz.',
374
858
  savedProducts: 'Saqlanganlar',
375
859
  orderStatus: {
376
860
  NEW: 'Yangi',
@@ -0,0 +1,70 @@
1
+ import { MagicStoreError, type Schema } from '@magicstoreai/storefront-client';
2
+
3
+ type Menu = Schema<'Menu'>;
4
+ type MenuItem = Schema<'MenuItem'>;
5
+ type Collection = Pick<Schema<'Collection'>, 'title' | 'handle'>;
6
+
7
+ /** One navigation link with its children, whatever it came from (a menu or the collections). */
8
+ export type NavItem = { title: string; href: string; children: NavItem[] };
9
+
10
+ /** Where a menu item leads: a collection, or a content page (policies are pages too). */
11
+ export function menuItemHref(item: Pick<MenuItem, 'type' | 'handle'>): string {
12
+ return item.type === 'COLLECTION' ? `/collections/${item.handle}` : `/pages/${item.handle}`;
13
+ }
14
+
15
+ function toNav(items: MenuItem[], keep: (item: MenuItem) => boolean): NavItem[] {
16
+ return items.filter(keep).map((item) => ({
17
+ title: item.title,
18
+ href: menuItemHref(item),
19
+ children: toNav(item.items, keep),
20
+ }));
21
+ }
22
+
23
+ /**
24
+ * The header's navigation: the merchant's `main` menu (the published collection tree), policies
25
+ * left out (they belong in the footer). With no menu, or an empty one, the top-level collections.
26
+ */
27
+ export function mainNav(menu: Menu | null, collections: Collection[]): NavItem[] {
28
+ const items = menu ? toNav(menu.items, (item) => item.type !== 'POLICY') : [];
29
+ if (items.length > 0) {
30
+ return items;
31
+ }
32
+ return collections.map((collection) => ({
33
+ title: collection.title,
34
+ href: `/collections/${collection.handle}`,
35
+ children: [],
36
+ }));
37
+ }
38
+
39
+ /**
40
+ * The footer's links from the `footer` menu: items with children become titled columns, the rest
41
+ * one list of links, and `POLICY` items (offer, privacy, returns) their own row at the bottom.
42
+ */
43
+ export function footerNav(menu: Menu | null): {
44
+ columns: NavItem[];
45
+ links: NavItem[];
46
+ policies: NavItem[];
47
+ } {
48
+ const items = menu?.items ?? [];
49
+ const nav = (item: MenuItem) => toNav([item], () => true)[0]!;
50
+ return {
51
+ columns: items.filter((item) => item.type !== 'POLICY' && item.items.length > 0).map(nav),
52
+ links: items.filter((item) => item.type !== 'POLICY' && item.items.length === 0).map(nav),
53
+ policies: items.filter((item) => item.type === 'POLICY').map(nav),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * A menu by handle, or `null` when the shop has none (`NOT_FOUND`). Any other failure is thrown:
59
+ * the error boundary shows it rather than a header quietly missing its links.
60
+ */
61
+ export async function readMenu(read: () => Promise<{ data: Menu }>): Promise<Menu | null> {
62
+ try {
63
+ return (await read()).data;
64
+ } catch (error) {
65
+ if (error instanceof MagicStoreError && error.code === 'NOT_FOUND') {
66
+ return null;
67
+ }
68
+ throw error;
69
+ }
70
+ }