@usequeek/theme-kit 0.1.20 → 0.1.22
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/README.md +264 -77
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +9 -7
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/image.tsx +5 -5
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +144 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +19 -18
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +16 -15
- package/locales/en.default.json +49 -7
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +263 -0
- package/strings/theme-strings.ts +185 -20
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +356 -2
- package/tests/fixtures/external-theme.tsx +18 -2
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +271 -0
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +10 -7
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +100 -14
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +56 -5
- package/utils/menu-link.ts +1 -1
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import type { JSX } from 'react';
|
|
3
3
|
import { useAuthFlow } from '../../hooks/use-auth-flow';
|
|
4
|
+
import { useThemeStrings } from '../../provider';
|
|
4
5
|
|
|
5
6
|
export function AuthEmailRegisterStep(): JSX.Element {
|
|
6
7
|
const { firstName, setFirstName, lastName, setLastName, email, setEmail, password, setPassword, submitEmailRegister, loading, error, goToEmailLogin } = useAuthFlow();
|
|
8
|
+
const t = useThemeStrings();
|
|
7
9
|
|
|
8
10
|
return (
|
|
9
11
|
<div className="core-auth__step core-auth__step--email-register">
|
|
10
|
-
<h2 className="core-auth__title">
|
|
11
|
-
<p className="core-auth__sub">
|
|
12
|
+
<h2 className="core-auth__title">{t('auth.register.title')}</h2>
|
|
13
|
+
<p className="core-auth__sub">{t('auth.register.subtitle')}</p>
|
|
12
14
|
<input
|
|
13
15
|
type="text"
|
|
14
16
|
className="core-input"
|
|
15
|
-
placeholder=
|
|
17
|
+
placeholder={t('auth.register.first')}
|
|
16
18
|
value={firstName}
|
|
17
19
|
onChange={(e) => setFirstName(e.target.value)}
|
|
18
20
|
autoComplete="given-name"
|
|
@@ -21,7 +23,7 @@ export function AuthEmailRegisterStep(): JSX.Element {
|
|
|
21
23
|
<input
|
|
22
24
|
type="text"
|
|
23
25
|
className="core-input"
|
|
24
|
-
placeholder=
|
|
26
|
+
placeholder={t('auth.register.last')}
|
|
25
27
|
value={lastName}
|
|
26
28
|
onChange={(e) => setLastName(e.target.value)}
|
|
27
29
|
autoComplete="family-name"
|
|
@@ -29,7 +31,7 @@ export function AuthEmailRegisterStep(): JSX.Element {
|
|
|
29
31
|
<input
|
|
30
32
|
type="email"
|
|
31
33
|
className="core-input"
|
|
32
|
-
placeholder=
|
|
34
|
+
placeholder={t('auth.register.email')}
|
|
33
35
|
value={email}
|
|
34
36
|
onChange={(e) => setEmail(e.target.value)}
|
|
35
37
|
autoComplete="email"
|
|
@@ -37,7 +39,7 @@ export function AuthEmailRegisterStep(): JSX.Element {
|
|
|
37
39
|
<input
|
|
38
40
|
type="password"
|
|
39
41
|
className="core-input"
|
|
40
|
-
placeholder=
|
|
42
|
+
placeholder={t('auth.register.password')}
|
|
41
43
|
value={password}
|
|
42
44
|
onChange={(e) => setPassword(e.target.value)}
|
|
43
45
|
onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailRegister(); }}
|
|
@@ -50,10 +52,10 @@ export function AuthEmailRegisterStep(): JSX.Element {
|
|
|
50
52
|
onClick={() => void submitEmailRegister()}
|
|
51
53
|
disabled={loading || !firstName.trim() || !email.trim() || password.length < 8}
|
|
52
54
|
>
|
|
53
|
-
{loading ? '
|
|
55
|
+
{loading ? t('auth.register.creating') : t('auth.register.title')}
|
|
54
56
|
</button>
|
|
55
57
|
<button type="button" className="core-auth__link" onClick={goToEmailLogin}>
|
|
56
|
-
|
|
58
|
+
{t('auth.register.signin')}
|
|
57
59
|
</button>
|
|
58
60
|
</div>
|
|
59
61
|
);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { JSX } from 'react';
|
|
4
4
|
import { useAuthFlow } from '../../hooks/use-auth-flow';
|
|
5
|
+
import { useThemeStrings } from '../../provider';
|
|
5
6
|
|
|
6
7
|
export function AuthSignupStep(): JSX.Element {
|
|
7
8
|
const {
|
|
@@ -11,16 +12,17 @@ export function AuthSignupStep(): JSX.Element {
|
|
|
11
12
|
loading, error,
|
|
12
13
|
goToEmailOtp,
|
|
13
14
|
} = useAuthFlow();
|
|
15
|
+
const t = useThemeStrings();
|
|
14
16
|
|
|
15
17
|
return (
|
|
16
18
|
<div className="core-auth__step core-auth__step--signup">
|
|
17
|
-
<h2 className="core-auth__title">
|
|
18
|
-
<p className="core-auth__sub">
|
|
19
|
+
<h2 className="core-auth__title">{t('auth.signup.title')}</h2>
|
|
20
|
+
<p className="core-auth__sub">{t('auth.signup.subtitle')}</p>
|
|
19
21
|
|
|
20
22
|
<input
|
|
21
23
|
type="text"
|
|
22
24
|
className="core-input"
|
|
23
|
-
placeholder=
|
|
25
|
+
placeholder={t('auth.signup.first')}
|
|
24
26
|
value={firstName}
|
|
25
27
|
onChange={(e) => setFirstName(e.target.value)}
|
|
26
28
|
autoComplete="given-name"
|
|
@@ -29,7 +31,7 @@ export function AuthSignupStep(): JSX.Element {
|
|
|
29
31
|
<input
|
|
30
32
|
type="text"
|
|
31
33
|
className="core-input"
|
|
32
|
-
placeholder=
|
|
34
|
+
placeholder={t('auth.signup.last')}
|
|
33
35
|
value={lastName}
|
|
34
36
|
onChange={(e) => setLastName(e.target.value)}
|
|
35
37
|
autoComplete="family-name"
|
|
@@ -43,11 +45,11 @@ export function AuthSignupStep(): JSX.Element {
|
|
|
43
45
|
onKeyDown={(e) => { if (e.key === 'Enter') void submitRegister(); }}
|
|
44
46
|
disabled={loading || !firstName.trim()}
|
|
45
47
|
>
|
|
46
|
-
{loading ? '
|
|
48
|
+
{loading ? t('auth.signup.creating') : t('auth.signup.create')}
|
|
47
49
|
</button>
|
|
48
50
|
|
|
49
51
|
<button type="button" className="core-auth__link" onClick={goToEmailOtp}>
|
|
50
|
-
|
|
52
|
+
{t('auth.signup.different')}
|
|
51
53
|
</button>
|
|
52
54
|
</div>
|
|
53
55
|
);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useStorefront, useThemeStrings } from '../../provider';
|
|
3
4
|
import type { JSX } from 'react';
|
|
4
5
|
import Link from 'next/link';
|
|
5
6
|
import type { BlogCategory } from '../../types/page';
|
|
6
|
-
import { useStorefront } from '../../provider';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Shared blog category browse bar. Links to `${basePath}/blog?category=slug`.
|
|
@@ -28,6 +28,7 @@ export function CategoryFilter({
|
|
|
28
28
|
className?: string;
|
|
29
29
|
}): JSX.Element | null {
|
|
30
30
|
const { basePath } = useStorefront();
|
|
31
|
+
const t = useThemeStrings();
|
|
31
32
|
|
|
32
33
|
if (!categories || categories.length === 0) {
|
|
33
34
|
return null;
|
|
@@ -37,14 +38,14 @@ export function CategoryFilter({
|
|
|
37
38
|
return (
|
|
38
39
|
<nav
|
|
39
40
|
className={`core-cat-tabs${className ? ` ${className}` : ''}`}
|
|
40
|
-
aria-label=
|
|
41
|
+
aria-label={t('blog.filter.nav')}
|
|
41
42
|
>
|
|
42
43
|
<Link
|
|
43
44
|
href={`${basePath}/blog`}
|
|
44
45
|
className={`core-cat-tabs__item${!active ? ' is-active' : ''}`}
|
|
45
46
|
aria-current={!active ? 'page' : undefined}
|
|
46
47
|
>
|
|
47
|
-
<span className="core-cat-tabs__label">
|
|
48
|
+
<span className="core-cat-tabs__label">{t('blog.filter.all')}</span>
|
|
48
49
|
</Link>
|
|
49
50
|
{categories.map((category) => {
|
|
50
51
|
const isActive = active === category.slug;
|
|
@@ -67,13 +68,13 @@ export function CategoryFilter({
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
return (
|
|
70
|
-
<nav className={`core-cat-filter${className ? ` ${className}` : ''}`} aria-label=
|
|
71
|
+
<nav className={`core-cat-filter${className ? ` ${className}` : ''}`} aria-label={t('blog.filter.nav')}>
|
|
71
72
|
<Link
|
|
72
73
|
href={`${basePath}/blog`}
|
|
73
74
|
className={`core-cat-filter__item${!active ? ' is-active' : ''}`}
|
|
74
75
|
aria-current={!active ? 'page' : undefined}
|
|
75
76
|
>
|
|
76
|
-
|
|
77
|
+
{t('blog.filter.all')}
|
|
77
78
|
</Link>
|
|
78
79
|
{categories.map((category) => {
|
|
79
80
|
const isActive = active === category.slug;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useStorefront, useThemeStrings } from '../../provider';
|
|
3
4
|
import type { JSX } from 'react';
|
|
4
5
|
import Link from 'next/link';
|
|
5
6
|
import type { Pagination } from '../../types/page';
|
|
6
|
-
import { useStorefront } from '../../provider';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Shared blog pagination (Prev / "Page N" / Next). SSR page-based navigation
|
|
@@ -21,6 +21,7 @@ export function BlogPagination({
|
|
|
21
21
|
className?: string;
|
|
22
22
|
}): JSX.Element | null {
|
|
23
23
|
const { basePath } = useStorefront();
|
|
24
|
+
const t = useThemeStrings();
|
|
24
25
|
|
|
25
26
|
if (!pagination) {
|
|
26
27
|
return null;
|
|
@@ -47,24 +48,24 @@ export function BlogPagination({
|
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
return (
|
|
50
|
-
<nav className={`core-pagination${className ? ` ${className}` : ''}`} aria-label=
|
|
51
|
+
<nav className={`core-pagination${className ? ` ${className}` : ''}`} aria-label={t('blog.pagination.nav')}>
|
|
51
52
|
{hasPrev ? (
|
|
52
53
|
<Link href={buildHref(current - 1)} className="core-pagination__link" rel="prev">
|
|
53
|
-
|
|
54
|
+
{t('blog.pagination.prev')}
|
|
54
55
|
</Link>
|
|
55
56
|
) : (
|
|
56
57
|
<span className="core-pagination__link is-disabled" aria-disabled="true">
|
|
57
|
-
|
|
58
|
+
{t('blog.pagination.prev')}
|
|
58
59
|
</span>
|
|
59
60
|
)}
|
|
60
|
-
<span className="core-pagination__status">{
|
|
61
|
+
<span className="core-pagination__status">{t('blog.pagination.page', { current })}</span>
|
|
61
62
|
{hasNext ? (
|
|
62
63
|
<Link href={buildHref(current + 1)} className="core-pagination__link" rel="next">
|
|
63
|
-
|
|
64
|
+
{t('blog.pagination.next')}
|
|
64
65
|
</Link>
|
|
65
66
|
) : (
|
|
66
67
|
<span className="core-pagination__link is-disabled" aria-disabled="true">
|
|
67
|
-
|
|
68
|
+
{t('blog.pagination.next')}
|
|
68
69
|
</span>
|
|
69
70
|
)}
|
|
70
71
|
</nav>
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useStorefront, useThemeStrings } from '../../provider';
|
|
3
4
|
import type { JSX } from 'react';
|
|
4
5
|
import type { Post } from '../../types/page';
|
|
5
|
-
import { useStorefront } from '../../provider';
|
|
6
6
|
import { Image } from '../image';
|
|
7
7
|
import { formatDisplayDate } from '../../utils/format';
|
|
8
|
+
import { resolveIntlLocale } from '../../utils/locale';
|
|
8
9
|
|
|
9
|
-
function formatDate(value: string): string {
|
|
10
|
+
function formatDate(value: string, locale: string | null): string {
|
|
10
11
|
const date = new Date(value);
|
|
11
12
|
if (Number.isNaN(date.getTime())) {
|
|
12
13
|
return '';
|
|
13
14
|
}
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
15
|
+
// Shopper's locale through the canonical mapping (English stays the
|
|
16
|
+
// British/Nigerian order, "15 February 2026"); the fixed zone keeps a post
|
|
17
|
+
// published near midnight UTC on the same day on the server and in the browser.
|
|
18
|
+
return formatDisplayDate(
|
|
19
|
+
date,
|
|
20
|
+
{ year: 'numeric', month: 'long', day: 'numeric' },
|
|
21
|
+
resolveIntlLocale(locale),
|
|
22
|
+
);
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -22,12 +28,13 @@ function formatDate(value: string): string {
|
|
|
22
28
|
* style via `.core-post-meta*` selectors (colours inherit brand vars).
|
|
23
29
|
*/
|
|
24
30
|
export function PostMeta({ post, className }: { post: Post; className?: string }): JSX.Element {
|
|
25
|
-
const { vendor } = useStorefront();
|
|
31
|
+
const { vendor, locale } = useStorefront();
|
|
32
|
+
const t = useThemeStrings();
|
|
26
33
|
|
|
27
34
|
const authorName = post.author?.name ?? vendor.name ?? null;
|
|
28
35
|
const authorAvatar = post.author?.avatar_url ?? vendor.logo ?? null;
|
|
29
|
-
const published = post.published_at ? formatDate(post.published_at) : '';
|
|
30
|
-
const readingTime = post.reading_time && post.reading_time > 0 ?
|
|
36
|
+
const published = post.published_at ? formatDate(post.published_at, locale) : '';
|
|
37
|
+
const readingTime = post.reading_time && post.reading_time > 0 ? t('blog.meta.reading', { count: post.reading_time }) : '';
|
|
31
38
|
|
|
32
39
|
return (
|
|
33
40
|
<div className={`core-post-meta${className ? ` ${className}` : ''}`}>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useStorefront, useThemeStrings } from '../../provider';
|
|
3
4
|
import type { JSX } from 'react';
|
|
4
5
|
import Link from 'next/link';
|
|
5
6
|
import type { Post } from '../../types/page';
|
|
6
|
-
import { useStorefront } from '../../provider';
|
|
7
7
|
import { Image } from '../image';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -13,13 +13,15 @@ import { Image } from '../image';
|
|
|
13
13
|
export function RelatedPosts({
|
|
14
14
|
posts,
|
|
15
15
|
className,
|
|
16
|
-
heading
|
|
16
|
+
heading,
|
|
17
17
|
}: {
|
|
18
18
|
posts?: Post[] | null;
|
|
19
19
|
className?: string;
|
|
20
20
|
heading?: string;
|
|
21
21
|
}): JSX.Element | null {
|
|
22
22
|
const { basePath } = useStorefront();
|
|
23
|
+
const t = useThemeStrings();
|
|
24
|
+
const title = heading ?? t('blog.related.title');
|
|
23
25
|
|
|
24
26
|
if (!posts || posts.length === 0) {
|
|
25
27
|
return null;
|
|
@@ -27,7 +29,7 @@ export function RelatedPosts({
|
|
|
27
29
|
|
|
28
30
|
return (
|
|
29
31
|
<section className={`core-related${className ? ` ${className}` : ''}`}>
|
|
30
|
-
<h2 className="core-related__heading">{
|
|
32
|
+
<h2 className="core-related__heading">{title}</h2>
|
|
31
33
|
<div className="core-related__grid">
|
|
32
34
|
{posts.map((post) => (
|
|
33
35
|
<Link key={post.id ?? post.slug} href={`${basePath}/blog/${post.slug}`} className="core-related__card">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { JSX } from 'react';
|
|
4
4
|
import { useEffect, useState } from 'react';
|
|
5
|
+
import { useThemeStrings } from '../../provider';
|
|
5
6
|
|
|
6
7
|
interface ShareButtonsProps {
|
|
7
8
|
title: string;
|
|
@@ -18,6 +19,7 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
|
|
|
18
19
|
const [url, setUrl] = useState('');
|
|
19
20
|
const [copied, setCopied] = useState(false);
|
|
20
21
|
const [canNativeShare, setCanNativeShare] = useState(false);
|
|
22
|
+
const t = useThemeStrings();
|
|
21
23
|
|
|
22
24
|
useEffect(() => {
|
|
23
25
|
setUrl(window.location.href);
|
|
@@ -47,11 +49,11 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
|
|
|
47
49
|
|
|
48
50
|
return (
|
|
49
51
|
<div className={`core-share${className ? ` ${className}` : ''}`}>
|
|
50
|
-
<span className="core-share__label">
|
|
52
|
+
<span className="core-share__label">{t('blog.share.label')}</span>
|
|
51
53
|
<div className="core-share__actions">
|
|
52
54
|
{canNativeShare ? (
|
|
53
|
-
<button type="button" className="core-share__btn" onClick={nativeShare} aria-label=
|
|
54
|
-
|
|
55
|
+
<button type="button" className="core-share__btn" onClick={nativeShare} aria-label={t('blog.share.action')}>
|
|
56
|
+
{t('blog.share.action')}
|
|
55
57
|
</button>
|
|
56
58
|
) : null}
|
|
57
59
|
<a
|
|
@@ -59,7 +61,7 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
|
|
|
59
61
|
href={`https://x.com/intent/post?url=${encodedUrl}&text=${encodedTitle}`}
|
|
60
62
|
target="_blank"
|
|
61
63
|
rel="noopener noreferrer"
|
|
62
|
-
aria-label=
|
|
64
|
+
aria-label={t('blog.share.x')}
|
|
63
65
|
>
|
|
64
66
|
X
|
|
65
67
|
</a>
|
|
@@ -68,7 +70,7 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
|
|
|
68
70
|
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
|
|
69
71
|
target="_blank"
|
|
70
72
|
rel="noopener noreferrer"
|
|
71
|
-
aria-label=
|
|
73
|
+
aria-label={t('blog.share.facebook')}
|
|
72
74
|
>
|
|
73
75
|
Facebook
|
|
74
76
|
</a>
|
|
@@ -77,12 +79,12 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
|
|
|
77
79
|
href={`https://wa.me/?text=${encodedTitle}%20${encodedUrl}`}
|
|
78
80
|
target="_blank"
|
|
79
81
|
rel="noopener noreferrer"
|
|
80
|
-
aria-label=
|
|
82
|
+
aria-label={t('blog.share.whatsapp')}
|
|
81
83
|
>
|
|
82
84
|
WhatsApp
|
|
83
85
|
</a>
|
|
84
|
-
<button type="button" className="core-share__btn" onClick={copyLink} aria-label=
|
|
85
|
-
{copied ? '
|
|
86
|
+
<button type="button" className="core-share__btn" onClick={copyLink} aria-label={t('blog.share.copy')}>
|
|
87
|
+
{copied ? t('blog.share.copied') : t('blog.share.copy')}
|
|
86
88
|
</button>
|
|
87
89
|
</div>
|
|
88
90
|
</div>
|
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
import type { JSX } from 'react';
|
|
4
4
|
import { useEffect, useRef, useState } from 'react';
|
|
5
5
|
import { useAuth } from '../../hooks/use-auth';
|
|
6
|
+
import { useThemeStrings } from '../../provider';
|
|
6
7
|
import { Image } from '../image';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* First section of the checkout form — shows who's checking out. Ordering
|
|
10
|
-
* requires auth (`canCheckout` in checkout-controller.tsx), so
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* requires auth (`canCheckout` in checkout-controller.tsx), so this is purely
|
|
12
|
+
* an identity confirmation, not an email field: avatar + email when signed in,
|
|
13
|
+
* a sign-in prompt otherwise.
|
|
13
14
|
*/
|
|
14
15
|
export function CheckoutContactSection(): JSX.Element {
|
|
15
16
|
const { user, isAuthenticated, openAuthModal, logout } = useAuth();
|
|
17
|
+
const t = useThemeStrings();
|
|
16
18
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
17
19
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
18
20
|
|
|
@@ -31,13 +33,13 @@ export function CheckoutContactSection(): JSX.Element {
|
|
|
31
33
|
return (
|
|
32
34
|
<section className="core-checkout__section core-checkout-contact">
|
|
33
35
|
<div className="core-checkout-contact__row">
|
|
34
|
-
<span className="core-checkout-contact__label">
|
|
36
|
+
<span className="core-checkout-contact__label">{t('checkout.contact.label')}</span>
|
|
35
37
|
<button
|
|
36
38
|
type="button"
|
|
37
39
|
className="core-checkout-contact__signin"
|
|
38
40
|
onClick={() => openAuthModal('login')}
|
|
39
41
|
>
|
|
40
|
-
|
|
42
|
+
{t('checkout.contact.signin')}
|
|
41
43
|
</button>
|
|
42
44
|
</div>
|
|
43
45
|
</section>
|
|
@@ -62,7 +64,7 @@ export function CheckoutContactSection(): JSX.Element {
|
|
|
62
64
|
<button
|
|
63
65
|
type="button"
|
|
64
66
|
className="core-checkout-contact__menu-btn"
|
|
65
|
-
aria-label=
|
|
67
|
+
aria-label={t('checkout.contact.options')}
|
|
66
68
|
onClick={() => setMenuOpen((v) => !v)}
|
|
67
69
|
>
|
|
68
70
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
@@ -74,7 +76,7 @@ export function CheckoutContactSection(): JSX.Element {
|
|
|
74
76
|
{menuOpen ? (
|
|
75
77
|
<div className="core-checkout-contact__menu">
|
|
76
78
|
<button type="button" onClick={() => { setMenuOpen(false); logout(); }}>
|
|
77
|
-
|
|
79
|
+
{t('checkout.contact.signout')}
|
|
78
80
|
</button>
|
|
79
81
|
</div>
|
|
80
82
|
) : null}
|
|
@@ -5,6 +5,7 @@ import type { CheckoutShellProps } from '../../types/theme';
|
|
|
5
5
|
import { formatMoney } from '../../utils/format';
|
|
6
6
|
import { useHref } from '../../hooks/use-href';
|
|
7
7
|
import { CheckoutContactSection } from './checkout-contact';
|
|
8
|
+
import { useThemeStrings } from '../../provider';
|
|
8
9
|
import { PolicyLinks } from './policy-links';
|
|
9
10
|
|
|
10
11
|
export function DefaultCheckoutShell({
|
|
@@ -29,14 +30,15 @@ export function DefaultCheckoutShell({
|
|
|
29
30
|
policies,
|
|
30
31
|
}: CheckoutShellProps): JSX.Element {
|
|
31
32
|
const href = useHref();
|
|
33
|
+
const t = useThemeStrings();
|
|
32
34
|
|
|
33
35
|
if (mode === 'page') {
|
|
34
36
|
if (totalCount === 0) {
|
|
35
37
|
return (
|
|
36
38
|
<div className="core-checkout core-checkout--page">
|
|
37
39
|
<div className="core-checkout__empty">
|
|
38
|
-
<p>
|
|
39
|
-
<Link href={href('/')} className="core-checkout__back-link">
|
|
40
|
+
<p>{t('checkout.empty.message')}</p>
|
|
41
|
+
<Link href={href('/')} className="core-checkout__back-link">{t('checkout.empty.browse')}</Link>
|
|
40
42
|
</div>
|
|
41
43
|
</div>
|
|
42
44
|
);
|
|
@@ -52,23 +54,23 @@ export function DefaultCheckoutShell({
|
|
|
52
54
|
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="14" height="14">
|
|
53
55
|
<path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
54
56
|
</svg>
|
|
55
|
-
|
|
57
|
+
{t('checkout.page.back')}
|
|
56
58
|
</Link>
|
|
57
59
|
</div>
|
|
58
60
|
<CheckoutContactSection />
|
|
59
61
|
<section className="core-checkout__section">
|
|
60
|
-
<h3 className="core-checkout__section-title">
|
|
62
|
+
<h3 className="core-checkout__section-title">{t('checkout.delivery.title')}</h3>
|
|
61
63
|
{deliveryModes}
|
|
62
64
|
</section>
|
|
63
65
|
{shippingZonePicker ? (
|
|
64
66
|
<section className="core-checkout__section">
|
|
65
|
-
<h3 className="core-checkout__section-title">
|
|
67
|
+
<h3 className="core-checkout__section-title">{t('checkout.zone.title')}</h3>
|
|
66
68
|
{shippingZonePicker}
|
|
67
69
|
</section>
|
|
68
70
|
) : null}
|
|
69
71
|
{addressPicker ? (
|
|
70
72
|
<section className="core-checkout__section">
|
|
71
|
-
<h3 className="core-checkout__section-title">
|
|
73
|
+
<h3 className="core-checkout__section-title">{t('checkout.address.title')}</h3>
|
|
72
74
|
{addressPicker}
|
|
73
75
|
</section>
|
|
74
76
|
) : null}
|
|
@@ -79,13 +81,13 @@ export function DefaultCheckoutShell({
|
|
|
79
81
|
) : null}
|
|
80
82
|
{schedulePicker ? (
|
|
81
83
|
<section className="core-checkout__section">
|
|
82
|
-
<h3 className="core-checkout__section-title">
|
|
84
|
+
<h3 className="core-checkout__section-title">{t('checkout.schedule.title')}</h3>
|
|
83
85
|
{schedulePicker}
|
|
84
86
|
</section>
|
|
85
87
|
) : null}
|
|
86
88
|
<section className="core-checkout__section">
|
|
87
|
-
<h3 className="core-checkout__payment-title">
|
|
88
|
-
<p className="core-checkout__payment-subtitle">
|
|
89
|
+
<h3 className="core-checkout__payment-title">{t('checkout.payment.title')}</h3>
|
|
90
|
+
<p className="core-checkout__payment-subtitle">{t('checkout.payment.secure')}</p>
|
|
89
91
|
{paymentPicker}
|
|
90
92
|
</section>
|
|
91
93
|
<section className="core-checkout__section">
|
|
@@ -97,7 +99,7 @@ export function DefaultCheckoutShell({
|
|
|
97
99
|
{/* Right pane: off-white — order summary */}
|
|
98
100
|
<div className="core-checkout__page-summary">
|
|
99
101
|
<div className="core-checkout__summary-card">
|
|
100
|
-
<h3 className="core-checkout__section-title">
|
|
102
|
+
<h3 className="core-checkout__section-title">{t('checkout.summary.title')}</h3>
|
|
101
103
|
{cartItems}
|
|
102
104
|
<div className="core-checkout__summary-promo">{promoInput}</div>
|
|
103
105
|
<div className="core-checkout__summary-fees">{feesBreakdown}</div>
|
|
@@ -123,7 +125,7 @@ export function DefaultCheckoutShell({
|
|
|
123
125
|
className={`core-checkout__tab${activeTab === 'summary' ? ' is-active' : ''}`}
|
|
124
126
|
onClick={() => onTabChange('summary')}
|
|
125
127
|
>
|
|
126
|
-
|
|
128
|
+
{t('checkout.tabs.summary')}{totalCount > 0 ? ` (${totalCount})` : ' '}
|
|
127
129
|
</button>
|
|
128
130
|
<button
|
|
129
131
|
type="button"
|
|
@@ -131,24 +133,24 @@ export function DefaultCheckoutShell({
|
|
|
131
133
|
onClick={() => onTabChange('checkout')}
|
|
132
134
|
disabled={totalCount === 0}
|
|
133
135
|
>
|
|
134
|
-
|
|
136
|
+
{t('checkout.tabs.checkout')}
|
|
135
137
|
</button>
|
|
136
138
|
</div>
|
|
137
139
|
|
|
138
140
|
{activeTab === 'summary' ? (
|
|
139
141
|
<div className="core-checkout__summary">
|
|
140
142
|
{totalCount === 0 ? (
|
|
141
|
-
<div className="core-checkout__empty"><p>
|
|
143
|
+
<div className="core-checkout__empty"><p>{t('checkout.summary.empty')}</p></div>
|
|
142
144
|
) : (
|
|
143
145
|
<>
|
|
144
146
|
{cartItems}
|
|
145
147
|
<div className="core-checkout__summary-footer">
|
|
146
148
|
<div className="core-checkout__subtotal">
|
|
147
|
-
<span>
|
|
149
|
+
<span>{t('checkout.summary.subtotal')}</span>
|
|
148
150
|
<span>{formatMoney(subtotal, currency)}</span>
|
|
149
151
|
</div>
|
|
150
152
|
<button type="button" className="core-submit-btn" onClick={() => onTabChange('checkout')}>
|
|
151
|
-
|
|
153
|
+
{t('checkout.summary.proceed')}
|
|
152
154
|
</button>
|
|
153
155
|
</div>
|
|
154
156
|
</>
|
|
@@ -6,6 +6,7 @@ import { createPortal } from 'react-dom';
|
|
|
6
6
|
import Link from 'next/link';
|
|
7
7
|
import type { Page } from '../../types/page';
|
|
8
8
|
import { useHref } from '../../hooks/use-href';
|
|
9
|
+
import { useThemeStrings } from '../../provider';
|
|
9
10
|
import { usePolicyModalStore } from '../../stores/policy-modal-store';
|
|
10
11
|
import { PageRenderer } from '../../page-renderer';
|
|
11
12
|
|
|
@@ -23,6 +24,7 @@ function subscribeNoop(): () => void {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | null {
|
|
27
|
+
const t = useThemeStrings();
|
|
26
28
|
const activeSlug = usePolicyModalStore((s) => s.activeSlug);
|
|
27
29
|
const open = usePolicyModalStore((s) => s.open);
|
|
28
30
|
const close = usePolicyModalStore((s) => s.close);
|
|
@@ -59,7 +61,7 @@ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | n
|
|
|
59
61
|
>
|
|
60
62
|
<div className="core-policy-modal__head">
|
|
61
63
|
<h2 className="core-policy-modal__title">{active.title}</h2>
|
|
62
|
-
<button type="button" className="core-policy-modal__close" aria-label=
|
|
64
|
+
<button type="button" className="core-policy-modal__close" aria-label={t('checkout.policy.close')} onClick={close}>
|
|
63
65
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
64
66
|
<path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
|
65
67
|
</svg>
|
|
@@ -70,7 +72,7 @@ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | n
|
|
|
70
72
|
</div>
|
|
71
73
|
<div className="core-policy-modal__foot">
|
|
72
74
|
<Link href={href(`/${active.slug}`)} className="core-policy-modal__full-link" onClick={close}>
|
|
73
|
-
|
|
75
|
+
{t('checkout.policy.view')}
|
|
74
76
|
</Link>
|
|
75
77
|
</div>
|
|
76
78
|
</div>
|
|
@@ -81,7 +83,7 @@ export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | n
|
|
|
81
83
|
|
|
82
84
|
return (
|
|
83
85
|
<>
|
|
84
|
-
<nav className="core-checkout-policies" aria-label=
|
|
86
|
+
<nav className="core-checkout-policies" aria-label={t('checkout.policy.list')}>
|
|
85
87
|
{policies.map((policy) => (
|
|
86
88
|
<button
|
|
87
89
|
key={policy.id}
|
package/components/image.tsx
CHANGED
|
@@ -35,11 +35,11 @@ type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
|
|
|
35
35
|
fallback?: string;
|
|
36
36
|
placeholder?: PlaceholderProp;
|
|
37
37
|
/**
|
|
38
|
-
* The
|
|
38
|
+
* The API's srcset-ready tier map (product.media.image_variants,
|
|
39
39
|
* vendor.logo_variants, collection.image_variants, mediaIndex[url], …).
|
|
40
|
-
* Purely additive: when absent —
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* Purely additive: when absent — and whenever the API legitimately sends null
|
|
41
|
+
* (off-host image, SVG, legacy upload) — this component renders the plain
|
|
42
|
+
* image URL.
|
|
43
43
|
*/
|
|
44
44
|
variants?: ImageVariants | null;
|
|
45
45
|
/**
|
|
@@ -133,7 +133,7 @@ export function Image({ fallback, placeholder, variants, intent = 'raw', onError
|
|
|
133
133
|
// image is invisible until hover. Eager, a server-rendered grid of 24 cards
|
|
134
134
|
// made React preload the first ~10 (also into the 103 Early Hints header) and
|
|
135
135
|
// fetch every image at once, racing the page's render-blocking CSS: /shop's
|
|
136
|
-
// first paint went 1.8 s
|
|
136
|
+
// first paint went from 1.8 s to 2.9 s on Slow 4G. Lazy is next/image's
|
|
137
137
|
// default as well. A theme whose card IS the page's main image passes
|
|
138
138
|
// loading="eager" or fetchPriority="high".
|
|
139
139
|
const lazyByDefault = (intent === 'card' || intent === 'card2x') && props.fetchPriority !== 'high';
|