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,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Money, useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { buttonClass } from '@/components/ui/button';
|
|
9
|
+
import { EmptyState } from '@/components/ui/empty-state';
|
|
10
|
+
import { Icon } from '@/components/ui/icon';
|
|
11
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
12
|
+
import { describe } from '@/lib/errors';
|
|
13
|
+
import { messages } from '@/lib/i18n';
|
|
14
|
+
|
|
15
|
+
import { formatDate, OrderStatus } from './order-status';
|
|
16
|
+
|
|
17
|
+
type Order = Schema<'Order'>;
|
|
18
|
+
|
|
19
|
+
/** The customer's orders, newest first, each a link to its page. */
|
|
20
|
+
export function OrderList() {
|
|
21
|
+
const client = useStorefrontClient();
|
|
22
|
+
const { locale } = useMagicStore();
|
|
23
|
+
const t = messages(locale);
|
|
24
|
+
const [orders, setOrders] = useState<Order[] | null>(null);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let current = true;
|
|
29
|
+
client.customerOrdersIndex({ query: { perPage: 20 } }).then(
|
|
30
|
+
({ data }) => current && setOrders(data),
|
|
31
|
+
(e: unknown) => current && setError(describe(e, t.tryAgainLater)),
|
|
32
|
+
);
|
|
33
|
+
return () => {
|
|
34
|
+
current = false;
|
|
35
|
+
};
|
|
36
|
+
}, [client, t.tryAgainLater]);
|
|
37
|
+
|
|
38
|
+
if (error) {
|
|
39
|
+
return (
|
|
40
|
+
<p className="error" role="alert">
|
|
41
|
+
{error}
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (orders === null) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="stack" aria-busy="true">
|
|
48
|
+
<p className="visually-hidden" role="status">
|
|
49
|
+
{t.loadingOrders}
|
|
50
|
+
</p>
|
|
51
|
+
{[0, 1, 2].map((index) => (
|
|
52
|
+
<Skeleton key={index} height="5.5rem" />
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (orders.length === 0) {
|
|
58
|
+
return (
|
|
59
|
+
<EmptyState
|
|
60
|
+
title={t.noOrders}
|
|
61
|
+
icon="cart"
|
|
62
|
+
action={
|
|
63
|
+
<Link href="/collections" className={buttonClass({ variant: 'primary' })}>
|
|
64
|
+
{t.shopNow}
|
|
65
|
+
</Link>
|
|
66
|
+
}
|
|
67
|
+
/>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
<ul className="order-list">
|
|
72
|
+
{orders.map((order) => {
|
|
73
|
+
const date = formatDate(order.createdAt, locale);
|
|
74
|
+
return (
|
|
75
|
+
<li key={order.id}>
|
|
76
|
+
<Link href={`/account/orders/${order.id}`} className="order-card">
|
|
77
|
+
<span className="order-card__main">
|
|
78
|
+
<span className="order-card__number">{t.orderNumber(order.number)}</span>
|
|
79
|
+
<span className="muted text-sm">
|
|
80
|
+
{[date && t.placedOn(date), t.itemsCount(order.totalQuantity)]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(' · ')}
|
|
83
|
+
</span>
|
|
84
|
+
</span>
|
|
85
|
+
<OrderStatus status={order.status} t={t} />
|
|
86
|
+
<Money data={order.cost.total} className="order-card__total" />
|
|
87
|
+
<Icon name="chevron-right" className="order-card__arrow" />
|
|
88
|
+
</Link>
|
|
89
|
+
</li>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</ul>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
2
|
+
|
|
3
|
+
import type { Messages } from '@/lib/i18n';
|
|
4
|
+
|
|
5
|
+
/** An order's status as a pill; the colour follows the status, the words come from `i18n`. */
|
|
6
|
+
export function OrderStatus({ status, t }: { status: Schema<'Order'>['status']; t: Messages }) {
|
|
7
|
+
return (
|
|
8
|
+
<span className={`order-status order-status--${status.toLowerCase()}`}>
|
|
9
|
+
{t.orderStatus[status]}
|
|
10
|
+
</span>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A date as the shop's language writes it, or nothing. */
|
|
15
|
+
export function formatDate(value: string | null, locale: string): string | null {
|
|
16
|
+
return value
|
|
17
|
+
? new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(value))
|
|
18
|
+
: null;
|
|
19
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCustomer, useMagicStore, useStorefrontClient } from '@magicstoreai/hydrogen';
|
|
4
|
+
import type { Schema } from '@magicstoreai/storefront-client';
|
|
5
|
+
import { useId, useState, type FormEvent } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import { Field } from '@/components/ui/field';
|
|
9
|
+
import { formErrors } from '@/lib/errors';
|
|
10
|
+
import { messages } from '@/lib/i18n';
|
|
11
|
+
|
|
12
|
+
type Gender = NonNullable<Schema<'CustomerUpdateRequest'>['gender']>;
|
|
13
|
+
|
|
14
|
+
/** The languages a customer may choose (`CustomerUpdateRequest.locale`), each in its own words. */
|
|
15
|
+
const LANGUAGES = { ru: 'Русский', uz: "O'zbekcha", en: 'English' } as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The customer's details (`customerUpdate`): name, gender, language. The phone is how they sign in;
|
|
19
|
+
* it is shown, not edited here (changing it needs a code). Then sign out.
|
|
20
|
+
*/
|
|
21
|
+
export function Profile() {
|
|
22
|
+
const client = useStorefrontClient();
|
|
23
|
+
const { customer, reload, signOut } = useCustomer();
|
|
24
|
+
const { locale } = useMagicStore();
|
|
25
|
+
const t = messages(locale);
|
|
26
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
const [saved, setSaved] = useState(false);
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const genderId = useId();
|
|
31
|
+
const languageId = useId();
|
|
32
|
+
|
|
33
|
+
if (customer === null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
const form = new FormData(event.currentTarget);
|
|
40
|
+
const gender = String(form.get('gender') ?? '');
|
|
41
|
+
setErrors({});
|
|
42
|
+
setError(null);
|
|
43
|
+
setSaved(false);
|
|
44
|
+
setBusy(true);
|
|
45
|
+
try {
|
|
46
|
+
await client.customerUpdate({
|
|
47
|
+
body: {
|
|
48
|
+
name: String(form.get('name') ?? '').trim(),
|
|
49
|
+
gender: gender === '' ? null : (gender as Gender),
|
|
50
|
+
locale: String(form.get('locale')) as keyof typeof LANGUAGES,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
await reload();
|
|
54
|
+
setSaved(true);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const { fields, summary } = formErrors(e, ['name'], t.tryAgainLater);
|
|
57
|
+
setErrors(fields);
|
|
58
|
+
setError(summary);
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="stack stack--loose">
|
|
66
|
+
<form className="profile-form" onSubmit={submit}>
|
|
67
|
+
<Field
|
|
68
|
+
label={t.name}
|
|
69
|
+
name="name"
|
|
70
|
+
autoComplete="name"
|
|
71
|
+
maxLength={255}
|
|
72
|
+
defaultValue={customer.name ?? ''}
|
|
73
|
+
error={errors.name}
|
|
74
|
+
/>
|
|
75
|
+
{customer.phone && (
|
|
76
|
+
<div className="field">
|
|
77
|
+
<span className="field__label">{t.phone}</span>
|
|
78
|
+
<p>{customer.phone}</p>
|
|
79
|
+
<p className="muted text-sm">{t.phoneHint}</p>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<div className="field">
|
|
83
|
+
<label className="field__label" htmlFor={genderId}>
|
|
84
|
+
{t.gender}
|
|
85
|
+
</label>
|
|
86
|
+
<select id={genderId} name="gender" defaultValue={customer.gender ?? ''}>
|
|
87
|
+
<option value="">{t.genderOptions.none}</option>
|
|
88
|
+
<option value="MALE">{t.genderOptions.MALE}</option>
|
|
89
|
+
<option value="FEMALE">{t.genderOptions.FEMALE}</option>
|
|
90
|
+
</select>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="field">
|
|
93
|
+
<label className="field__label" htmlFor={languageId}>
|
|
94
|
+
{t.language}
|
|
95
|
+
</label>
|
|
96
|
+
<select
|
|
97
|
+
id={languageId}
|
|
98
|
+
name="locale"
|
|
99
|
+
defaultValue={
|
|
100
|
+
[customer.locale, locale].find((code) => code && code in LANGUAGES) ?? 'en'
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
{Object.entries(LANGUAGES).map(([code, name]) => (
|
|
104
|
+
<option key={code} value={code} lang={code}>
|
|
105
|
+
{name}
|
|
106
|
+
</option>
|
|
107
|
+
))}
|
|
108
|
+
</select>
|
|
109
|
+
</div>
|
|
110
|
+
{error && (
|
|
111
|
+
<p className="error" role="alert">
|
|
112
|
+
{error}
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
<div className="row">
|
|
116
|
+
<Button type="submit" variant="primary" loading={busy}>
|
|
117
|
+
{t.saveChanges}
|
|
118
|
+
</Button>
|
|
119
|
+
<span className="muted text-sm" role="status">
|
|
120
|
+
{saved ? t.changesSaved : ''}
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
</form>
|
|
124
|
+
<div>
|
|
125
|
+
<Button onClick={() => void signOut()}>{t.signOut}</Button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCustomer,
|
|
5
|
+
useMagicStore,
|
|
6
|
+
useTelegramSignIn,
|
|
7
|
+
useTelegramWebApp,
|
|
8
|
+
} from '@magicstoreai/hydrogen';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { Field } from '@/components/ui/field';
|
|
13
|
+
import { describe } from '@/lib/errors';
|
|
14
|
+
import { messages } from '@/lib/i18n';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sign-in: inside a Telegram Mini App the Telegram button first, then the phone form (a code by
|
|
18
|
+
* SMS, or the phone alone where the shop has sign-in codes off).
|
|
19
|
+
*/
|
|
20
|
+
export function SignIn() {
|
|
21
|
+
const { status, isTelegram } = useTelegramWebApp();
|
|
22
|
+
const { locale, shop } = useMagicStore();
|
|
23
|
+
const t = messages(locale);
|
|
24
|
+
// Until the app has looked (`unknown`, server render included), a shop with a bot may be open in
|
|
25
|
+
// Telegram: hold the Telegram block's place so the phone form does not jump down after mount.
|
|
26
|
+
const maybeTelegram = status === 'unknown' && shop?.features.telegramBot === true;
|
|
27
|
+
return (
|
|
28
|
+
<div className="auth-card">
|
|
29
|
+
<h1 className="auth-card__title">{t.signIn}</h1>
|
|
30
|
+
{isTelegram && <TelegramBlock />}
|
|
31
|
+
{maybeTelegram && <TelegramBlock placeholder />}
|
|
32
|
+
<PhoneSignIn />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Telegram sign-in above the phone form. As a `placeholder` it renders the same markup, hidden,
|
|
39
|
+
* so the reserved space always matches the real block.
|
|
40
|
+
*/
|
|
41
|
+
function TelegramBlock({ placeholder = false }: { placeholder?: boolean }) {
|
|
42
|
+
const t = messages(useMagicStore().locale);
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className="stack"
|
|
46
|
+
aria-hidden={placeholder || undefined}
|
|
47
|
+
style={placeholder ? { visibility: 'hidden' } : undefined}
|
|
48
|
+
>
|
|
49
|
+
<TelegramSignIn />
|
|
50
|
+
<p className="auth-card__divider">{t.orWithPhone}</p>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Inside a Mini App: sign in with the launch data. The automatic sign-in runs in
|
|
57
|
+
* components/telegram-shell.tsx; this is the manual retry. The hook shares the attempt's state, so
|
|
58
|
+
* this shows the automatic attempt too: "Signing in…" while it runs, the relaunch hint when it failed.
|
|
59
|
+
*/
|
|
60
|
+
function TelegramSignIn() {
|
|
61
|
+
const { status, error, signIn } = useTelegramSignIn({ auto: false });
|
|
62
|
+
const t = messages(useMagicStore().locale);
|
|
63
|
+
// FORBIDDEN: the shop has no bot, so retrying is pointless.
|
|
64
|
+
const unavailable = error?.code === 'FORBIDDEN';
|
|
65
|
+
return (
|
|
66
|
+
<div className="stack">
|
|
67
|
+
{!unavailable && (
|
|
68
|
+
<button
|
|
69
|
+
className="button button--primary button--lg button--full"
|
|
70
|
+
disabled={status === 'signing-in'}
|
|
71
|
+
onClick={() => void signIn()}
|
|
72
|
+
>
|
|
73
|
+
{status === 'signing-in' ? t.signingIn : t.signInWithTelegram}
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
{status === 'failed' && (
|
|
77
|
+
<p className="error" role="alert">
|
|
78
|
+
{error?.code === 'UNAUTHENTICATED'
|
|
79
|
+
? t.telegramRelaunch
|
|
80
|
+
: unavailable
|
|
81
|
+
? t.telegramUnavailable
|
|
82
|
+
: describe(error, t.tryAgainLater)}
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Phone sign-in: a code by SMS, or — on a shop whose sign-in code is off
|
|
91
|
+
* (`shop.features.otpLogin === false`) — the phone alone.
|
|
92
|
+
*/
|
|
93
|
+
function PhoneSignIn() {
|
|
94
|
+
const { requestOtp, verifyOtp, signInWithPhone } = useCustomer();
|
|
95
|
+
const { locale, shop } = useMagicStore();
|
|
96
|
+
const t = messages(locale);
|
|
97
|
+
const withoutCode = shop?.features.otpLogin === false;
|
|
98
|
+
const [phone, setPhone] = useState('');
|
|
99
|
+
const [sent, setSent] = useState(false);
|
|
100
|
+
const [code, setCode] = useState('');
|
|
101
|
+
const [busy, setBusy] = useState(false);
|
|
102
|
+
const [error, setError] = useState<string | null>(null);
|
|
103
|
+
|
|
104
|
+
async function attempt(step: () => Promise<unknown>) {
|
|
105
|
+
setError(null);
|
|
106
|
+
setBusy(true);
|
|
107
|
+
try {
|
|
108
|
+
await step();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
setError(describe(e, t.tryAgainLater));
|
|
111
|
+
} finally {
|
|
112
|
+
setBusy(false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<form
|
|
118
|
+
className="stack"
|
|
119
|
+
onSubmit={(event) => {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
void attempt(async () => {
|
|
122
|
+
if (withoutCode) {
|
|
123
|
+
await signInWithPhone(phone);
|
|
124
|
+
} else if (!sent) {
|
|
125
|
+
await requestOtp(phone);
|
|
126
|
+
setSent(true);
|
|
127
|
+
} else {
|
|
128
|
+
await verifyOtp(phone, code);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<Field
|
|
134
|
+
label={t.phone}
|
|
135
|
+
value={phone}
|
|
136
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
137
|
+
placeholder="+998 90 123 45 67"
|
|
138
|
+
type="tel"
|
|
139
|
+
autoComplete="tel"
|
|
140
|
+
required
|
|
141
|
+
disabled={sent}
|
|
142
|
+
/>
|
|
143
|
+
{sent && (
|
|
144
|
+
<Field
|
|
145
|
+
label={t.smsCode}
|
|
146
|
+
value={code}
|
|
147
|
+
onChange={(e) => setCode(e.target.value)}
|
|
148
|
+
inputMode="numeric"
|
|
149
|
+
autoComplete="one-time-code"
|
|
150
|
+
required
|
|
151
|
+
autoFocus
|
|
152
|
+
/>
|
|
153
|
+
)}
|
|
154
|
+
<Button type="submit" variant="primary" size="lg" full loading={busy}>
|
|
155
|
+
{sent || withoutCode ? t.signIn : t.sendCode}
|
|
156
|
+
</Button>
|
|
157
|
+
{error && (
|
|
158
|
+
<p className="error" role="alert">
|
|
159
|
+
{error}
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
</form>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { articleBodyBlocks, type ArticleBlock } from '@magicstoreai/hydrogen/core';
|
|
2
2
|
import type { Schema } from '@magicstoreai/storefront-client';
|
|
3
|
-
import { ProductCard } from '@/components/product-
|
|
3
|
+
import { ProductCard } from '@/components/product-card';
|
|
4
|
+
import { locale } from '@/lib/api';
|
|
4
5
|
|
|
5
6
|
type Product = Schema<'Product'>;
|
|
6
7
|
|
|
@@ -34,7 +35,7 @@ function Blocks({
|
|
|
34
35
|
const product = products.get(block.handle);
|
|
35
36
|
return product ? (
|
|
36
37
|
<div key={index} className="article-product">
|
|
37
|
-
<ProductCard product={product} />
|
|
38
|
+
<ProductCard product={product} locale={locale} />
|
|
38
39
|
</div>
|
|
39
40
|
) : null;
|
|
40
41
|
}
|
|
@@ -22,19 +22,44 @@ function formatDate(iso: string, timeZone: string): string {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* One article as a card: cover, category, title, excerpt, then author, date and reading time.
|
|
27
|
+
* `feature` lays it out large, the cover beside the text: the blog's lead article.
|
|
28
|
+
*/
|
|
29
|
+
export function ArticleCard({
|
|
30
|
+
article,
|
|
31
|
+
timeZone,
|
|
32
|
+
feature = false,
|
|
33
|
+
}: {
|
|
34
|
+
article: Article;
|
|
35
|
+
timeZone: string;
|
|
36
|
+
feature?: boolean;
|
|
37
|
+
}) {
|
|
27
38
|
const t = messages(locale);
|
|
28
39
|
return (
|
|
29
|
-
<Link
|
|
30
|
-
|
|
31
|
-
{article
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
<Link
|
|
41
|
+
href={`/blog/${article.handle}`}
|
|
42
|
+
className={feature ? 'article-card article-card--feature' : 'article-card'}
|
|
43
|
+
>
|
|
44
|
+
<span className="article-card__media">
|
|
45
|
+
<Image
|
|
46
|
+
data={article.image}
|
|
47
|
+
alt=""
|
|
48
|
+
loading={feature ? 'eager' : 'lazy'}
|
|
49
|
+
fallback={<span className="media-fallback" />}
|
|
50
|
+
/>
|
|
51
|
+
</span>
|
|
52
|
+
<span className="article-card__body">
|
|
53
|
+
{article.category && (
|
|
54
|
+
<span className="article-card__category">{article.category.title}</span>
|
|
55
|
+
)}
|
|
56
|
+
<span className="article-card__title">{article.title}</span>
|
|
57
|
+
{article.excerpt && <span className="article-card__excerpt">{article.excerpt}</span>}
|
|
58
|
+
<span className="article-card__meta">
|
|
59
|
+
{article.authorName && <span>{article.authorName}</span>}
|
|
60
|
+
<ArticleDate iso={article.publishedAt} timeZone={timeZone} />
|
|
61
|
+
<span>{t.readingTime(article.readingMinutes)}</span>
|
|
62
|
+
</span>
|
|
38
63
|
</span>
|
|
39
64
|
</Link>
|
|
40
65
|
);
|
|
@@ -45,7 +70,7 @@ export function ArticleGrid({ articles, timeZone }: { articles: Article[]; timeZ
|
|
|
45
70
|
return <p className="muted">{messages(locale).nothingHere}</p>;
|
|
46
71
|
}
|
|
47
72
|
return (
|
|
48
|
-
<div className="grid
|
|
73
|
+
<div className="article-grid">
|
|
49
74
|
{articles.map((article) => (
|
|
50
75
|
<ArticleCard key={article.id} article={article} timeZone={timeZone} />
|
|
51
76
|
))}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Money, useCart, useMagicStore } from '@magicstoreai/hydrogen';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
import { buttonClass } from '@/components/ui/button';
|
|
8
|
+
import { Drawer } from '@/components/ui/drawer';
|
|
9
|
+
import { EmptyState } from '@/components/ui/empty-state';
|
|
10
|
+
import { describe } from '@/lib/errors';
|
|
11
|
+
import { messages } from '@/lib/i18n';
|
|
12
|
+
|
|
13
|
+
import { CartLines } from './cart-lines';
|
|
14
|
+
|
|
15
|
+
/** `DRAWER`: adding opens the side panel; `PAGE`: the buyer stays and hears "added". */
|
|
16
|
+
export type CartType = 'DRAWER' | 'PAGE';
|
|
17
|
+
|
|
18
|
+
type CartDrawerContext = {
|
|
19
|
+
cartType: CartType;
|
|
20
|
+
isOpen: boolean;
|
|
21
|
+
open: () => void;
|
|
22
|
+
close: () => void;
|
|
23
|
+
/** Call after a successful add: opens the drawer, or announces the add (`cartType: PAGE`). */
|
|
24
|
+
added: () => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const Context = createContext<CartDrawerContext | null>(null);
|
|
28
|
+
|
|
29
|
+
/** The cart drawer's state, for the header icon and every add-to-cart button. */
|
|
30
|
+
export function useCartDrawer(): CartDrawerContext {
|
|
31
|
+
const context = useContext(Context);
|
|
32
|
+
if (context === null) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'useCartDrawer() must be used inside <CartDrawerProvider> (app/providers.tsx).',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return context;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Holds the drawer's open state and renders the drawer once for the whole app, plus a polite live
|
|
42
|
+
* region that announces an add to screen readers whatever `cartType` is.
|
|
43
|
+
*/
|
|
44
|
+
export function CartDrawerProvider({
|
|
45
|
+
cartType,
|
|
46
|
+
children,
|
|
47
|
+
}: {
|
|
48
|
+
cartType: CartType;
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
const t = messages(useMagicStore().locale);
|
|
52
|
+
const [isOpen, setOpen] = useState(false);
|
|
53
|
+
const [announcement, setAnnouncement] = useState('');
|
|
54
|
+
const open = useCallback(() => setOpen(true), []);
|
|
55
|
+
const close = useCallback(() => setOpen(false), []);
|
|
56
|
+
const added = useCallback(() => {
|
|
57
|
+
// A new string each time, so a second add is announced too.
|
|
58
|
+
setAnnouncement((previous) =>
|
|
59
|
+
previous === t.addedToCart ? `${t.addedToCart} ` : t.addedToCart,
|
|
60
|
+
);
|
|
61
|
+
if (cartType === 'DRAWER') {
|
|
62
|
+
setOpen(true);
|
|
63
|
+
}
|
|
64
|
+
}, [cartType, t.addedToCart]);
|
|
65
|
+
const value = useMemo(
|
|
66
|
+
() => ({ cartType, isOpen, open, close, added }),
|
|
67
|
+
[cartType, isOpen, open, close, added],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Context.Provider value={value}>
|
|
72
|
+
{children}
|
|
73
|
+
<CartDrawer open={isOpen} onClose={close} />
|
|
74
|
+
<p className="visually-hidden" aria-live="polite">
|
|
75
|
+
{announcement}
|
|
76
|
+
</p>
|
|
77
|
+
</Context.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The cart in a right-hand panel: its lines, the subtotal from the cart (never computed here), a
|
|
83
|
+
* note that delivery comes at checkout, and the ways on: checkout and the cart page.
|
|
84
|
+
*/
|
|
85
|
+
function CartDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
86
|
+
const { cart, error } = useCart();
|
|
87
|
+
const t = messages(useMagicStore().locale);
|
|
88
|
+
const empty = cart === null || cart.lines.length === 0;
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Drawer
|
|
92
|
+
open={open}
|
|
93
|
+
onClose={onClose}
|
|
94
|
+
title={t.cart}
|
|
95
|
+
closeLabel={t.close}
|
|
96
|
+
className="cart-drawer"
|
|
97
|
+
footer={
|
|
98
|
+
empty ? undefined : (
|
|
99
|
+
<div className="cart-drawer__summary">
|
|
100
|
+
<div className="cart-drawer__subtotal">
|
|
101
|
+
<span>{t.subtotal}</span>
|
|
102
|
+
<Money data={cart.cost.subtotal} />
|
|
103
|
+
</div>
|
|
104
|
+
<p className="cart-drawer__note">{t.deliveryAtCheckout}</p>
|
|
105
|
+
{error !== null && (
|
|
106
|
+
<p className="error" role="alert">
|
|
107
|
+
{describe(error, t.tryAgainLater)}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
<Link
|
|
111
|
+
href="/checkout"
|
|
112
|
+
className={buttonClass({ variant: 'primary', size: 'lg', full: true })}
|
|
113
|
+
onClick={onClose}
|
|
114
|
+
>
|
|
115
|
+
{t.checkout}
|
|
116
|
+
</Link>
|
|
117
|
+
<Link
|
|
118
|
+
href="/cart"
|
|
119
|
+
className={buttonClass({ variant: 'link', full: true })}
|
|
120
|
+
onClick={onClose}
|
|
121
|
+
>
|
|
122
|
+
{t.viewCart}
|
|
123
|
+
</Link>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
{empty ? (
|
|
129
|
+
<EmptyState
|
|
130
|
+
title={t.cartEmpty}
|
|
131
|
+
icon="cart"
|
|
132
|
+
action={
|
|
133
|
+
<Link
|
|
134
|
+
href="/collections"
|
|
135
|
+
className={buttonClass({ variant: 'primary' })}
|
|
136
|
+
onClick={onClose}
|
|
137
|
+
>
|
|
138
|
+
{t.shopNow}
|
|
139
|
+
</Link>
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
{t.cartEmptyHint}
|
|
143
|
+
</EmptyState>
|
|
144
|
+
) : (
|
|
145
|
+
<CartLines onNavigate={onClose} />
|
|
146
|
+
)}
|
|
147
|
+
</Drawer>
|
|
148
|
+
);
|
|
149
|
+
}
|