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.
- package/package.json +3 -3
- package/template/.claude/skills/storefront-design/SKILL.md +14 -10
- package/template/.env.example +5 -0
- package/template/AGENTS.md +36 -17
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +116 -29
- package/template/README.md +15 -3
- package/template/app/account/addresses/page.tsx +16 -0
- package/template/app/account/layout.tsx +14 -0
- package/template/app/account/loading.tsx +15 -0
- package/template/app/account/orders/[id]/page.tsx +15 -0
- package/template/app/account/page.tsx +12 -244
- package/template/app/account/profile/page.tsx +16 -0
- package/template/app/blog/(index)/loading.tsx +22 -0
- package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
- package/template/app/blog/[handle]/page.tsx +92 -56
- package/template/app/blog/author/[handle]/page.tsx +1 -1
- package/template/app/cart/layout.tsx +14 -0
- package/template/app/cart/page.tsx +49 -101
- package/template/app/checkout/layout.tsx +14 -0
- package/template/app/checkout/page.tsx +98 -281
- package/template/app/collections/[handle]/layout.tsx +20 -0
- package/template/app/collections/[handle]/loading.tsx +23 -0
- package/template/app/collections/[handle]/page.tsx +131 -15
- package/template/app/collections/page.tsx +49 -0
- package/template/app/contacts/page.tsx +196 -0
- package/template/app/error.tsx +19 -8
- package/template/app/fonts/OFL.txt +93 -0
- package/template/app/fonts/index.ts +14 -0
- package/template/app/fonts/onest-variable.woff2 +0 -0
- package/template/app/globals.css +14 -518
- package/template/app/layout.tsx +74 -50
- package/template/app/not-found.tsx +34 -4
- package/template/app/page.tsx +1 -1
- package/template/app/pages/[handle]/page.tsx +1 -1
- package/template/app/products/[handle]/layout.tsx +20 -0
- package/template/app/products/[handle]/loading.tsx +25 -0
- package/template/app/products/[handle]/page.tsx +112 -15
- package/template/app/providers.tsx +20 -7
- package/template/app/search/loading.tsx +12 -0
- package/template/app/search/page.tsx +138 -25
- package/template/app/sitemap.ts +5 -1
- package/template/app/storefront-api/[...path]/route.ts +11 -66
- package/template/app/styles/base.css +138 -0
- package/template/app/styles/components.css +1108 -0
- package/template/app/styles/layout.css +455 -0
- package/template/app/styles/pages.css +1839 -0
- package/template/app/styles/reset.css +55 -0
- package/template/app/styles/sections.css +415 -0
- package/template/app/styles/tokens.css +167 -0
- package/template/app/styles/utilities.css +37 -0
- package/template/app/wishlist/page.tsx +26 -0
- package/template/components/account/account-shell.tsx +100 -0
- package/template/components/account/addresses.tsx +247 -0
- package/template/components/account/order-detail.tsx +281 -0
- package/template/components/account/order-list.tsx +94 -0
- package/template/components/account/order-status.tsx +19 -0
- package/template/components/account/profile.tsx +129 -0
- package/template/components/account/sign-in.tsx +164 -0
- package/template/components/article-body.tsx +3 -2
- package/template/components/article-card.tsx +37 -12
- package/template/components/cart/cart-drawer.tsx +149 -0
- package/template/components/cart/cart-lines.tsx +89 -0
- package/template/components/cart/cart-summary.tsx +191 -0
- package/template/components/catalog/catalog-form.tsx +107 -0
- package/template/components/catalog/catalog-layout.tsx +82 -0
- package/template/components/catalog/facets-shell.tsx +60 -0
- package/template/components/catalog/facets.tsx +216 -0
- package/template/components/checkout/order-summary.tsx +84 -0
- package/template/components/checkout/radio-card.tsx +34 -0
- package/template/components/checkout/step-indicator.tsx +56 -0
- package/template/components/checkout/steps.tsx +264 -0
- package/template/components/checkout/thank-you.tsx +48 -0
- package/template/components/checkout/use-checkout.ts +90 -0
- package/template/components/footer/site-footer.tsx +132 -0
- package/template/components/header/cart-button.tsx +43 -0
- package/template/components/header/desktop-nav.tsx +133 -0
- package/template/components/header/mobile-menu.tsx +102 -0
- package/template/components/header/site-header.tsx +78 -0
- package/template/components/page-loading.tsx +20 -0
- package/template/components/pager.tsx +3 -1
- package/template/components/product/buy-box.tsx +148 -0
- package/template/components/product/gallery.tsx +179 -0
- package/template/components/product/pre-order-form.tsx +93 -0
- package/template/components/product/product-selection.tsx +31 -0
- package/template/components/product/recommendations.tsx +26 -0
- package/template/components/product/review-list.tsx +159 -0
- package/template/components/product/reviews.tsx +80 -0
- package/template/components/product/variant-picker.tsx +75 -0
- package/template/components/product-card.tsx +103 -0
- package/template/components/product-grid.tsx +27 -57
- package/template/components/quick-add.tsx +62 -0
- package/template/components/search/predictive-search.tsx +282 -0
- package/template/components/sections/announcement-bar.tsx +1 -1
- package/template/components/sections/banner.tsx +1 -1
- package/template/components/sections/collections.tsx +50 -13
- package/template/components/sections/deal-of-day.tsx +2 -2
- package/template/components/sections/product-shelves.tsx +13 -5
- package/template/components/sections/shoppable-stories.tsx +2 -2
- package/template/components/sections/store-reviews.tsx +10 -4
- package/template/components/share-button.tsx +42 -0
- package/template/components/ui/accordion.tsx +35 -0
- package/template/components/ui/badge.tsx +20 -0
- package/template/components/ui/breadcrumbs.tsx +31 -0
- package/template/components/ui/button.tsx +90 -0
- package/template/components/ui/drawer.tsx +84 -0
- package/template/components/ui/empty-state.tsx +27 -0
- package/template/components/ui/field.tsx +33 -0
- package/template/components/ui/icon.tsx +112 -0
- package/template/components/ui/price.tsx +35 -0
- package/template/components/ui/quantity-stepper.tsx +91 -0
- package/template/components/ui/rating.tsx +48 -0
- package/template/components/ui/skeleton.tsx +46 -0
- package/template/components/wishlist/wishlist-view.tsx +134 -0
- package/template/lib/article-outline.ts +35 -0
- package/template/lib/catalog-params.ts +170 -0
- package/template/lib/contacts.ts +34 -0
- package/template/lib/errors.ts +29 -0
- package/template/lib/i18n.ts +484 -0
- package/template/lib/menu.ts +70 -0
- package/template/lib/theme-settings.ts +36 -0
- package/template/lib/upstream.ts +21 -0
- package/template/llms.txt +13 -0
- package/template/theme/index.ts +7 -1
- package/template/theme/sections/collapsible-content.tsx +75 -0
- package/template/theme/sections/collection-list.tsx +32 -4
- package/template/theme/sections/featured-collection.tsx +22 -2
- package/template/theme/sections/hero.tsx +94 -7
- package/template/theme/sections/image-with-text.tsx +19 -3
- package/template/theme/sections/multicolumn.tsx +113 -0
- package/template/theme/sections/page-content.tsx +10 -4
- package/template/theme/sections/product-shelf.tsx +26 -4
- package/template/theme/sections/rich-text.tsx +13 -1
- package/template/theme/sections/testimonials.tsx +14 -9
- package/template/theme/settings.ts +85 -1
- package/template/theme/templates/index.json +4 -2
- package/template/.screenshots/home-1280.png +0 -0
- package/template/.screenshots/home-390.png +0 -0
- package/template/.screenshots/home-filled-1280.png +0 -0
- package/template/.screenshots/home-filled-390.png +0 -0
- package/template/.screenshots/page-1280.png +0 -0
- package/template/components/buy-box.tsx +0 -89
- 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
|
+
}
|
package/template/lib/upstream.ts
CHANGED
|
@@ -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
|
|
package/template/theme/index.ts
CHANGED
|
@@ -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.
|
|
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
|
|
60
|
-
{settings.heading &&
|
|
61
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 &&
|
|
58
|
-
|
|
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="
|
|
37
|
-
|
|
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">{
|
|
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
|
}
|