create-magic-storefront 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/template/.claude/skills/storefront-design/SKILL.md +14 -10
- package/template/AGENTS.md +32 -16
- package/template/DESIGN.md +117 -0
- package/template/PAGES.md +107 -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 +73 -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 +5 -1
- 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/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/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
|
@@ -1,13 +1,43 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
|
+
import { buttonClass } from '@/components/ui/button';
|
|
3
|
+
import { EmptyState } from '@/components/ui/empty-state';
|
|
4
|
+
import { Icon } from '@/components/ui/icon';
|
|
2
5
|
import { locale } from '@/lib/api';
|
|
3
6
|
import { messages } from '@/lib/i18n';
|
|
4
7
|
|
|
8
|
+
/** A page that is not there (or not in this language): search, the catalog, or home. */
|
|
5
9
|
export default function NotFound() {
|
|
6
10
|
const t = messages(locale);
|
|
7
11
|
return (
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
<EmptyState
|
|
13
|
+
as="h1"
|
|
14
|
+
title={t.notFound}
|
|
15
|
+
icon="search"
|
|
16
|
+
action={
|
|
17
|
+
<div className="not-found__actions">
|
|
18
|
+
<form role="search" action="/search" className="search-form not-found__search">
|
|
19
|
+
<Icon name="search" className="search-form__icon" />
|
|
20
|
+
<input
|
|
21
|
+
type="search"
|
|
22
|
+
name="q"
|
|
23
|
+
placeholder={t.searchPlaceholder}
|
|
24
|
+
aria-label={t.search}
|
|
25
|
+
enterKeyHint="search"
|
|
26
|
+
/>
|
|
27
|
+
<button type="submit" className="button button--primary">
|
|
28
|
+
{t.search}
|
|
29
|
+
</button>
|
|
30
|
+
</form>
|
|
31
|
+
<div className="row">
|
|
32
|
+
<Link href="/collections" className={buttonClass()}>
|
|
33
|
+
{t.catalog}
|
|
34
|
+
</Link>
|
|
35
|
+
<Link href="/" className={buttonClass({ variant: 'link' })}>
|
|
36
|
+
{t.backToShop}
|
|
37
|
+
</Link>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
}
|
|
41
|
+
/>
|
|
12
42
|
);
|
|
13
43
|
}
|
package/template/app/page.tsx
CHANGED
|
@@ -37,7 +37,7 @@ export default async function ContentPage({ params }: Props) {
|
|
|
37
37
|
'page-content': (props) => <PageContent {...props} page={page} />,
|
|
38
38
|
};
|
|
39
39
|
return (
|
|
40
|
-
<div className="
|
|
40
|
+
<div className="page-sections">
|
|
41
41
|
<ThemeSections sections={sections} components={pageComponents} />
|
|
42
42
|
</div>
|
|
43
43
|
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { api, orNotFound } from '@/lib/api';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The product must exist before the page streams: `loading.tsx` below sends the headers, so a
|
|
7
|
+
* `notFound()` in the page would answer 200. Checked here, a missing handle is a real 404. The page
|
|
8
|
+
* reads the same request from the cache.
|
|
9
|
+
*/
|
|
10
|
+
export default async function ProductLayout({
|
|
11
|
+
children,
|
|
12
|
+
params,
|
|
13
|
+
}: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
params: Promise<{ handle: string }>;
|
|
16
|
+
}) {
|
|
17
|
+
const { handle } = await params;
|
|
18
|
+
await orNotFound(api.productsShow({ path: { handle } }));
|
|
19
|
+
return children;
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { PageLoading } from '@/components/page-loading';
|
|
2
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
3
|
+
|
|
4
|
+
export default function ProductLoading() {
|
|
5
|
+
return (
|
|
6
|
+
<PageLoading>
|
|
7
|
+
<Skeleton height="1rem" width="16rem" className="breadcrumbs" />
|
|
8
|
+
<div className="product-page">
|
|
9
|
+
<div className="product-page__media">
|
|
10
|
+
<div className="gallery__slide">
|
|
11
|
+
<Skeleton className="product-loading__media" />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div className="product-page__info">
|
|
15
|
+
<Skeleton height="2rem" width="90%" />
|
|
16
|
+
<Skeleton height="2rem" width="60%" />
|
|
17
|
+
<Skeleton height="1.75rem" width="30%" />
|
|
18
|
+
<Skeleton height="2.75rem" width="70%" />
|
|
19
|
+
<Skeleton height="3rem" />
|
|
20
|
+
<Skeleton height="4rem" />
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</PageLoading>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import {
|
|
3
|
+
breadcrumbJsonLd,
|
|
4
|
+
jsonLdScript,
|
|
5
|
+
pageMeta,
|
|
6
|
+
productJsonLd,
|
|
7
|
+
} from '@magicstoreai/hydrogen/seo';
|
|
8
|
+
import { BuyBox } from '@/components/product/buy-box';
|
|
9
|
+
import { Gallery } from '@/components/product/gallery';
|
|
10
|
+
import { ProductSelection } from '@/components/product/product-selection';
|
|
11
|
+
import { Recommendations } from '@/components/product/recommendations';
|
|
12
|
+
import { ProductReviews, REVIEWS_PER_PAGE } from '@/components/product/reviews';
|
|
13
|
+
import { ShareButton } from '@/components/share-button';
|
|
14
|
+
import { Accordion } from '@/components/ui/accordion';
|
|
15
|
+
import { Breadcrumbs } from '@/components/ui/breadcrumbs';
|
|
16
|
+
import { Rating } from '@/components/ui/rating';
|
|
17
|
+
import { api, locale, orNotFound } from '@/lib/api';
|
|
18
|
+
import { messages } from '@/lib/i18n';
|
|
6
19
|
|
|
7
20
|
type Props = { params: Promise<{ handle: string }> };
|
|
8
21
|
|
|
@@ -22,26 +35,110 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
22
35
|
};
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A product: the gallery beside a sticky buy box (stacked on phones), the description and FAQ as
|
|
40
|
+
* accordions, the reviews, and two recommendation shelves ("pairs well with", "you may also
|
|
41
|
+
* like"). Prices, stock and recommendations are the API's; the page only lays them out.
|
|
42
|
+
*/
|
|
25
43
|
export default async function ProductPage({ params }: Props) {
|
|
26
44
|
const { handle } = await params;
|
|
45
|
+
const t = messages(locale);
|
|
27
46
|
const { data: product } = await orNotFound(api.productsShow({ path: { handle } }));
|
|
47
|
+
// Reviews and recommendations sit below the buy box: when one of them fails, the product is still
|
|
48
|
+
// shown and bought — that part is left out (no section, an empty shelf), not the whole page.
|
|
49
|
+
const [{ data: shop }, reviews, related, complementary] = await Promise.all([
|
|
50
|
+
api.shop(),
|
|
51
|
+
api
|
|
52
|
+
.productsReviews({ path: { handle }, query: { perPage: REVIEWS_PER_PAGE } })
|
|
53
|
+
.catch(() => null),
|
|
54
|
+
api
|
|
55
|
+
.productsRecommendations({ path: { handle }, query: { intent: 'RELATED', perPage: 8 } })
|
|
56
|
+
.then(
|
|
57
|
+
({ data }) => data,
|
|
58
|
+
() => [],
|
|
59
|
+
),
|
|
60
|
+
api
|
|
61
|
+
.productsRecommendations({ path: { handle }, query: { intent: 'COMPLEMENTARY', perPage: 4 } })
|
|
62
|
+
.then(
|
|
63
|
+
({ data }) => data,
|
|
64
|
+
() => [],
|
|
65
|
+
),
|
|
66
|
+
]);
|
|
67
|
+
const path = `/products/${product.handle}`;
|
|
68
|
+
const crumbs = [
|
|
69
|
+
{ name: t.home, href: '/' },
|
|
70
|
+
...(product.primaryCollection
|
|
71
|
+
? [
|
|
72
|
+
{
|
|
73
|
+
name: product.primaryCollection.title,
|
|
74
|
+
href: `/collections/${product.primaryCollection.handle}`,
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
: []),
|
|
78
|
+
{ name: product.title, href: path },
|
|
79
|
+
];
|
|
28
80
|
|
|
29
81
|
return (
|
|
30
|
-
|
|
82
|
+
<>
|
|
83
|
+
<script
|
|
84
|
+
type="application/ld+json"
|
|
85
|
+
dangerouslySetInnerHTML={{ __html: jsonLdScript(productJsonLd(product, { url: path })) }}
|
|
86
|
+
/>
|
|
31
87
|
<script
|
|
32
88
|
type="application/ld+json"
|
|
33
89
|
dangerouslySetInnerHTML={{
|
|
34
|
-
__html: jsonLdScript(
|
|
90
|
+
__html: jsonLdScript(
|
|
91
|
+
breadcrumbJsonLd(crumbs.map((crumb) => ({ name: crumb.name, url: crumb.href }))),
|
|
92
|
+
),
|
|
35
93
|
}}
|
|
36
94
|
/>
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
95
|
+
<Breadcrumbs items={crumbs} label={t.breadcrumbs} />
|
|
96
|
+
<ProductSelection product={product}>
|
|
97
|
+
<article className="product-page">
|
|
98
|
+
<div className="product-page__media">
|
|
99
|
+
<Gallery images={product.images} video={product.video} title={product.title} />
|
|
100
|
+
</div>
|
|
101
|
+
<div className="product-page__info">
|
|
102
|
+
<header className="product-page__header">
|
|
103
|
+
{product.vendor && <p className="product-page__vendor">{product.vendor}</p>}
|
|
104
|
+
<h1 className="product-page__title">{product.title}</h1>
|
|
105
|
+
{product.rating.count > 0 && (
|
|
106
|
+
<a href="#reviews" className="product-page__rating">
|
|
107
|
+
<Rating
|
|
108
|
+
average={product.rating.average}
|
|
109
|
+
count={product.rating.count}
|
|
110
|
+
label={t.ratingSummary(product.rating.average ?? 0, product.rating.count)}
|
|
111
|
+
/>
|
|
112
|
+
</a>
|
|
113
|
+
)}
|
|
114
|
+
</header>
|
|
115
|
+
<BuyBox wishlist={shop.features.wishlist} deliveryText={shop.checkout.deliveryText} />
|
|
116
|
+
{(product.description || product.faq.length > 0) && (
|
|
117
|
+
<div className="product-page__details">
|
|
118
|
+
{product.description && (
|
|
119
|
+
<Accordion title={t.description} open>
|
|
120
|
+
<div
|
|
121
|
+
className="prose"
|
|
122
|
+
dangerouslySetInnerHTML={{ __html: product.description }}
|
|
123
|
+
/>
|
|
124
|
+
</Accordion>
|
|
125
|
+
)}
|
|
126
|
+
{product.faq.map((entry, index) => (
|
|
127
|
+
<Accordion key={index} title={entry.question}>
|
|
128
|
+
<p>{entry.answer}</p>
|
|
129
|
+
</Accordion>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
<ShareButton title={product.title} />
|
|
134
|
+
</div>
|
|
135
|
+
</article>
|
|
136
|
+
</ProductSelection>
|
|
137
|
+
{reviews && (
|
|
138
|
+
<ProductReviews handle={product.handle} reviews={reviews} locale={locale} t={t} />
|
|
139
|
+
)}
|
|
140
|
+
<Recommendations title={t.pairsWellWith} products={complementary} locale={locale} />
|
|
141
|
+
<Recommendations title={t.relatedProducts} products={related} locale={locale} />
|
|
142
|
+
</>
|
|
46
143
|
);
|
|
47
144
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { MagicStoreProvider, useAnalytics, type Shop } from '@magicstoreai/hydrogen';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
5
|
import { useEffect, useRef, type ReactNode } from 'react';
|
|
6
|
+
import { CartDrawerProvider, type CartType } from '@/components/cart/cart-drawer';
|
|
6
7
|
|
|
7
8
|
const token = process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN;
|
|
8
9
|
const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
|
|
@@ -16,10 +17,13 @@ const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
|
|
|
16
17
|
export function Providers({
|
|
17
18
|
shop,
|
|
18
19
|
credentials,
|
|
20
|
+
cartType,
|
|
19
21
|
children,
|
|
20
22
|
}: {
|
|
21
23
|
shop: Shop;
|
|
22
24
|
credentials: 'browser' | 'server';
|
|
25
|
+
/** The `cartType` theme setting: what adding to the cart does. */
|
|
26
|
+
cartType: CartType;
|
|
23
27
|
children: ReactNode;
|
|
24
28
|
}) {
|
|
25
29
|
const connection = token
|
|
@@ -28,7 +32,7 @@ export function Providers({
|
|
|
28
32
|
return (
|
|
29
33
|
<MagicStoreProvider {...connection} shop={shop} locale={locale}>
|
|
30
34
|
<PageViews />
|
|
31
|
-
{children}
|
|
35
|
+
<CartDrawerProvider cartType={cartType}>{children}</CartDrawerProvider>
|
|
32
36
|
</MagicStoreProvider>
|
|
33
37
|
);
|
|
34
38
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PageLoading } from '@/components/page-loading';
|
|
2
|
+
import { ProductGridSkeleton, Skeleton, TitleSkeleton } from '@/components/ui/skeleton';
|
|
3
|
+
|
|
4
|
+
export default function SearchLoading() {
|
|
5
|
+
return (
|
|
6
|
+
<PageLoading>
|
|
7
|
+
<TitleSkeleton width="20%" />
|
|
8
|
+
<Skeleton height="2.75rem" className="search-form" />
|
|
9
|
+
<ProductGridSkeleton />
|
|
10
|
+
</PageLoading>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
2
3
|
import { SearchView } from '@/components/analytics-views';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { CatalogLayout } from '@/components/catalog/catalog-layout';
|
|
5
|
+
import { buttonClass } from '@/components/ui/button';
|
|
6
|
+
import { EmptyState } from '@/components/ui/empty-state';
|
|
7
|
+
import { Icon } from '@/components/ui/icon';
|
|
5
8
|
import { api, locale } from '@/lib/api';
|
|
6
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
apiQuery,
|
|
11
|
+
catalogQuery,
|
|
12
|
+
cleared,
|
|
13
|
+
hasFilters,
|
|
14
|
+
parseCatalog,
|
|
15
|
+
SEARCH_QUERY_MAX,
|
|
16
|
+
type SearchParams,
|
|
17
|
+
} from '@/lib/catalog-params';
|
|
18
|
+
import { messages, type Messages } from '@/lib/i18n';
|
|
7
19
|
|
|
8
|
-
type Props = { searchParams: Promise<
|
|
20
|
+
type Props = { searchParams: Promise<SearchParams> };
|
|
21
|
+
type SearchQuery = NonNullable<NonNullable<Parameters<typeof api.search>[0]>['query']>;
|
|
9
22
|
|
|
10
23
|
// Result pages are endless and change by the minute: keep them out of the index.
|
|
11
24
|
export const metadata: Metadata = {
|
|
@@ -13,30 +26,130 @@ export const metadata: Metadata = {
|
|
|
13
26
|
robots: { index: false, follow: true },
|
|
14
27
|
};
|
|
15
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Search: the query field, then the results with the same facets and sort as a collection
|
|
31
|
+
* (`searchFilters` for this query). Without a query, or with no result, the shop's popular searches
|
|
32
|
+
* help the buyer on.
|
|
33
|
+
*/
|
|
16
34
|
export default async function SearchPage({ searchParams }: Props) {
|
|
17
|
-
const
|
|
18
|
-
|
|
35
|
+
const params = await searchParams;
|
|
36
|
+
// Cut to what the API accepts: a pasted essay searches its start instead of failing the page.
|
|
37
|
+
const q = ((Array.isArray(params.q) ? params.q[0] : params.q) ?? '')
|
|
38
|
+
.slice(0, SEARCH_QUERY_MAX)
|
|
39
|
+
.trim();
|
|
19
40
|
const t = messages(locale);
|
|
20
|
-
const results = q.trim() ? await api.search({ query: { q, page, perPage: 24 } }) : null;
|
|
21
41
|
|
|
42
|
+
if (q === '') {
|
|
43
|
+
return (
|
|
44
|
+
<div className="search-page">
|
|
45
|
+
<h1 className="page-title">{t.search}</h1>
|
|
46
|
+
<SearchForm q="" t={t} />
|
|
47
|
+
<Trending queries={await trending()} t={t} />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [{ data: filters }, { data: shop }] = await Promise.all([
|
|
53
|
+
api.searchFilters({ query: { q } }),
|
|
54
|
+
api.shop(),
|
|
55
|
+
]);
|
|
56
|
+
const catalog = parseCatalog(params, filters);
|
|
57
|
+
const results = await api.search({
|
|
58
|
+
query: { q, perPage: 24, ...apiQuery(catalog) } as SearchQuery,
|
|
59
|
+
});
|
|
60
|
+
const total = results.meta.pagination.total;
|
|
61
|
+
const popular = total === 0 && !hasFilters(catalog) ? await trending() : [];
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="search-page">
|
|
65
|
+
<SearchView query={q} resultsCount={total} />
|
|
66
|
+
<h1 className="page-title">{t.search}</h1>
|
|
67
|
+
<SearchForm q={q} t={t} />
|
|
68
|
+
<CatalogLayout
|
|
69
|
+
filters={filters}
|
|
70
|
+
catalog={catalog}
|
|
71
|
+
products={results}
|
|
72
|
+
path="/search"
|
|
73
|
+
extra={{ q }}
|
|
74
|
+
money={{ locale, format: shop.moneyFormat }}
|
|
75
|
+
locale={locale}
|
|
76
|
+
t={t}
|
|
77
|
+
empty={
|
|
78
|
+
hasFilters(catalog) ? (
|
|
79
|
+
<EmptyState
|
|
80
|
+
title={t.noMatches}
|
|
81
|
+
icon="filter"
|
|
82
|
+
action={
|
|
83
|
+
<Link
|
|
84
|
+
href={`/search${catalogQuery(cleared(catalog), { q })}`}
|
|
85
|
+
className={buttonClass()}
|
|
86
|
+
>
|
|
87
|
+
{t.clearAll}
|
|
88
|
+
</Link>
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{t.noMatchesHint}
|
|
92
|
+
</EmptyState>
|
|
93
|
+
) : (
|
|
94
|
+
<>
|
|
95
|
+
<EmptyState
|
|
96
|
+
title={t.noSuggestions(q)}
|
|
97
|
+
icon="search"
|
|
98
|
+
action={
|
|
99
|
+
<Link href="/collections" className={buttonClass({ variant: 'primary' })}>
|
|
100
|
+
{t.shopNow}
|
|
101
|
+
</Link>
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
<Trending queries={popular} t={t} />
|
|
105
|
+
</>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function SearchForm({ q, t }: { q: string; t: Messages }) {
|
|
114
|
+
return (
|
|
115
|
+
<form role="search" action="/search" className="search-form">
|
|
116
|
+
<Icon name="search" className="search-form__icon" />
|
|
117
|
+
<input
|
|
118
|
+
type="search"
|
|
119
|
+
name="q"
|
|
120
|
+
defaultValue={q}
|
|
121
|
+
maxLength={SEARCH_QUERY_MAX}
|
|
122
|
+
placeholder={t.searchPlaceholder}
|
|
123
|
+
aria-label={t.search}
|
|
124
|
+
enterKeyHint="search"
|
|
125
|
+
/>
|
|
126
|
+
<button type="submit" className="button button--primary">
|
|
127
|
+
{t.search}
|
|
128
|
+
</button>
|
|
129
|
+
</form>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function trending(): Promise<string[]> {
|
|
134
|
+
const { data } = await api.searchTrending({ query: { perPage: 8 } });
|
|
135
|
+
return data.map((entry) => entry.query);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The shop's popular searches as links; nothing when it has none yet. */
|
|
139
|
+
function Trending({ queries, t }: { queries: string[]; t: Messages }) {
|
|
140
|
+
if (queries.length === 0) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
22
143
|
return (
|
|
23
|
-
|
|
24
|
-
<
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<ProductGrid products={results.data} />
|
|
34
|
-
<Pager
|
|
35
|
-
meta={results.meta.pagination}
|
|
36
|
-
href={(p) => `/search?q=${encodeURIComponent(q)}&page=${p}`}
|
|
37
|
-
/>
|
|
38
|
-
</>
|
|
39
|
-
)}
|
|
40
|
-
</>
|
|
144
|
+
<section className="search-trending">
|
|
145
|
+
<h2 className="search-trending__title">{t.popularSearches}</h2>
|
|
146
|
+
<nav className="chips" aria-label={t.popularSearches}>
|
|
147
|
+
{queries.map((query) => (
|
|
148
|
+
<Link key={query} href={`/search?${new URLSearchParams({ q: query })}`}>
|
|
149
|
+
{query}
|
|
150
|
+
</Link>
|
|
151
|
+
))}
|
|
152
|
+
</nav>
|
|
153
|
+
</section>
|
|
41
154
|
);
|
|
42
155
|
}
|
package/template/app/sitemap.ts
CHANGED
|
@@ -25,7 +25,11 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
25
25
|
const { data: shop } = await api.shop();
|
|
26
26
|
const origin = siteOrigin(shop);
|
|
27
27
|
|
|
28
|
-
const entries: MetadataRoute.Sitemap = [
|
|
28
|
+
const entries: MetadataRoute.Sitemap = [
|
|
29
|
+
{ url: `${origin}/` },
|
|
30
|
+
{ url: `${origin}/collections` },
|
|
31
|
+
{ url: `${origin}/contacts` },
|
|
32
|
+
];
|
|
29
33
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
30
34
|
const { data, meta } = await api.sitemap({ query: { page, perPage: PER_PAGE } });
|
|
31
35
|
for (const row of data) {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* Elements without classes: type, links, form controls, focus. */
|
|
2
|
+
@layer base {
|
|
3
|
+
html {
|
|
4
|
+
scroll-padding-top: calc(var(--header-h) + var(--space-4));
|
|
5
|
+
}
|
|
6
|
+
body {
|
|
7
|
+
min-height: 100dvh;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
font-size: var(--text-base);
|
|
11
|
+
line-height: var(--leading-normal);
|
|
12
|
+
background: var(--bg);
|
|
13
|
+
color: var(--fg);
|
|
14
|
+
-webkit-font-smoothing: antialiased;
|
|
15
|
+
}
|
|
16
|
+
html[data-telegram] body {
|
|
17
|
+
/* Telegram's stable viewport (the sheet can be dragged), the dynamic viewport outside it. */
|
|
18
|
+
min-height: var(--tg-viewport-stable-height, 100dvh);
|
|
19
|
+
padding-left: max(env(safe-area-inset-left), var(--tg-safe-area-inset-left, 0px));
|
|
20
|
+
padding-right: max(env(safe-area-inset-right), var(--tg-safe-area-inset-right, 0px));
|
|
21
|
+
padding-bottom: max(env(safe-area-inset-bottom), var(--tg-safe-area-inset-bottom, 0px));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
h1,
|
|
25
|
+
h2,
|
|
26
|
+
h3,
|
|
27
|
+
h4 {
|
|
28
|
+
font-family: var(--font-heading);
|
|
29
|
+
font-weight: var(--weight-heading);
|
|
30
|
+
line-height: var(--leading-tight);
|
|
31
|
+
letter-spacing: -0.01em;
|
|
32
|
+
text-wrap: balance;
|
|
33
|
+
}
|
|
34
|
+
h1 {
|
|
35
|
+
font-size: var(--text-3xl);
|
|
36
|
+
}
|
|
37
|
+
h2 {
|
|
38
|
+
font-size: var(--text-2xl);
|
|
39
|
+
}
|
|
40
|
+
h3 {
|
|
41
|
+
font-size: var(--text-lg);
|
|
42
|
+
line-height: var(--leading-snug);
|
|
43
|
+
}
|
|
44
|
+
p {
|
|
45
|
+
text-wrap: pretty;
|
|
46
|
+
}
|
|
47
|
+
a {
|
|
48
|
+
color: inherit;
|
|
49
|
+
text-underline-offset: 0.2em;
|
|
50
|
+
text-decoration-thickness: 1px;
|
|
51
|
+
}
|
|
52
|
+
s {
|
|
53
|
+
text-decoration-thickness: 1px;
|
|
54
|
+
}
|
|
55
|
+
hr {
|
|
56
|
+
border: 0;
|
|
57
|
+
border-top: 1px solid var(--line);
|
|
58
|
+
margin: var(--space-5) 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
:focus-visible {
|
|
62
|
+
outline: 2px solid var(--fg);
|
|
63
|
+
outline-offset: 2px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* Bare controls get a calm default; .button and the form components refine them. */
|
|
67
|
+
button {
|
|
68
|
+
min-height: 44px;
|
|
69
|
+
padding: 0 var(--space-4);
|
|
70
|
+
border: 1px solid var(--line-strong);
|
|
71
|
+
border-radius: var(--radius-sm);
|
|
72
|
+
background: var(--surface);
|
|
73
|
+
color: var(--fg);
|
|
74
|
+
transition:
|
|
75
|
+
background-color var(--duration-fast) var(--ease),
|
|
76
|
+
border-color var(--duration-fast) var(--ease);
|
|
77
|
+
}
|
|
78
|
+
button:disabled {
|
|
79
|
+
opacity: 0.5;
|
|
80
|
+
cursor: not-allowed;
|
|
81
|
+
}
|
|
82
|
+
button[aria-pressed='true'] {
|
|
83
|
+
border-color: var(--fg);
|
|
84
|
+
box-shadow: inset 0 0 0 1px var(--fg);
|
|
85
|
+
}
|
|
86
|
+
input:not([type='checkbox'], [type='radio'], [type='range']),
|
|
87
|
+
select,
|
|
88
|
+
textarea {
|
|
89
|
+
width: 100%;
|
|
90
|
+
min-height: 44px;
|
|
91
|
+
padding: var(--space-2) var(--space-3);
|
|
92
|
+
/* 16px at least: smaller makes iOS zoom into the field. */
|
|
93
|
+
font-size: max(var(--text-base), 16px);
|
|
94
|
+
background: var(--surface);
|
|
95
|
+
color: var(--fg);
|
|
96
|
+
border: 1px solid var(--line-strong);
|
|
97
|
+
border-radius: var(--radius-sm);
|
|
98
|
+
}
|
|
99
|
+
input:focus-visible,
|
|
100
|
+
select:focus-visible,
|
|
101
|
+
textarea:focus-visible {
|
|
102
|
+
outline: 2px solid var(--fg);
|
|
103
|
+
outline-offset: 0;
|
|
104
|
+
border-color: var(--fg);
|
|
105
|
+
}
|
|
106
|
+
input::placeholder,
|
|
107
|
+
textarea::placeholder {
|
|
108
|
+
color: var(--muted);
|
|
109
|
+
}
|
|
110
|
+
input[type='checkbox'],
|
|
111
|
+
input[type='radio'] {
|
|
112
|
+
width: 1.125rem;
|
|
113
|
+
height: 1.125rem;
|
|
114
|
+
margin: 0;
|
|
115
|
+
accent-color: var(--fg);
|
|
116
|
+
}
|
|
117
|
+
label {
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
}
|
|
120
|
+
table {
|
|
121
|
+
width: 100%;
|
|
122
|
+
border-collapse: collapse;
|
|
123
|
+
}
|
|
124
|
+
td,
|
|
125
|
+
th {
|
|
126
|
+
padding: var(--space-3) var(--space-2);
|
|
127
|
+
border-bottom: 1px solid var(--line);
|
|
128
|
+
text-align: start;
|
|
129
|
+
vertical-align: middle;
|
|
130
|
+
}
|
|
131
|
+
blockquote {
|
|
132
|
+
margin: 0;
|
|
133
|
+
}
|
|
134
|
+
progress {
|
|
135
|
+
width: 100%;
|
|
136
|
+
accent-color: var(--accent);
|
|
137
|
+
}
|
|
138
|
+
}
|