create-magic-storefront 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/package.json +3 -3
  2. package/template/.claude/skills/storefront-design/SKILL.md +14 -10
  3. package/template/.env.example +5 -0
  4. package/template/AGENTS.md +36 -17
  5. package/template/DESIGN.md +117 -0
  6. package/template/PAGES.md +116 -29
  7. package/template/README.md +15 -3
  8. package/template/app/account/addresses/page.tsx +16 -0
  9. package/template/app/account/layout.tsx +14 -0
  10. package/template/app/account/loading.tsx +15 -0
  11. package/template/app/account/orders/[id]/page.tsx +15 -0
  12. package/template/app/account/page.tsx +12 -244
  13. package/template/app/account/profile/page.tsx +16 -0
  14. package/template/app/blog/(index)/loading.tsx +22 -0
  15. package/template/app/blog/{page.tsx → (index)/page.tsx} +42 -25
  16. package/template/app/blog/[handle]/page.tsx +92 -56
  17. package/template/app/blog/author/[handle]/page.tsx +1 -1
  18. package/template/app/cart/layout.tsx +14 -0
  19. package/template/app/cart/page.tsx +49 -101
  20. package/template/app/checkout/layout.tsx +14 -0
  21. package/template/app/checkout/page.tsx +98 -281
  22. package/template/app/collections/[handle]/layout.tsx +20 -0
  23. package/template/app/collections/[handle]/loading.tsx +23 -0
  24. package/template/app/collections/[handle]/page.tsx +131 -15
  25. package/template/app/collections/page.tsx +49 -0
  26. package/template/app/contacts/page.tsx +196 -0
  27. package/template/app/error.tsx +19 -8
  28. package/template/app/fonts/OFL.txt +93 -0
  29. package/template/app/fonts/index.ts +14 -0
  30. package/template/app/fonts/onest-variable.woff2 +0 -0
  31. package/template/app/globals.css +14 -518
  32. package/template/app/layout.tsx +74 -50
  33. package/template/app/not-found.tsx +34 -4
  34. package/template/app/page.tsx +1 -1
  35. package/template/app/pages/[handle]/page.tsx +1 -1
  36. package/template/app/products/[handle]/layout.tsx +20 -0
  37. package/template/app/products/[handle]/loading.tsx +25 -0
  38. package/template/app/products/[handle]/page.tsx +112 -15
  39. package/template/app/providers.tsx +20 -7
  40. package/template/app/search/loading.tsx +12 -0
  41. package/template/app/search/page.tsx +138 -25
  42. package/template/app/sitemap.ts +5 -1
  43. package/template/app/storefront-api/[...path]/route.ts +11 -66
  44. package/template/app/styles/base.css +138 -0
  45. package/template/app/styles/components.css +1108 -0
  46. package/template/app/styles/layout.css +455 -0
  47. package/template/app/styles/pages.css +1839 -0
  48. package/template/app/styles/reset.css +55 -0
  49. package/template/app/styles/sections.css +415 -0
  50. package/template/app/styles/tokens.css +167 -0
  51. package/template/app/styles/utilities.css +37 -0
  52. package/template/app/wishlist/page.tsx +26 -0
  53. package/template/components/account/account-shell.tsx +100 -0
  54. package/template/components/account/addresses.tsx +247 -0
  55. package/template/components/account/order-detail.tsx +281 -0
  56. package/template/components/account/order-list.tsx +94 -0
  57. package/template/components/account/order-status.tsx +19 -0
  58. package/template/components/account/profile.tsx +129 -0
  59. package/template/components/account/sign-in.tsx +164 -0
  60. package/template/components/article-body.tsx +3 -2
  61. package/template/components/article-card.tsx +37 -12
  62. package/template/components/cart/cart-drawer.tsx +149 -0
  63. package/template/components/cart/cart-lines.tsx +89 -0
  64. package/template/components/cart/cart-summary.tsx +191 -0
  65. package/template/components/catalog/catalog-form.tsx +107 -0
  66. package/template/components/catalog/catalog-layout.tsx +82 -0
  67. package/template/components/catalog/facets-shell.tsx +60 -0
  68. package/template/components/catalog/facets.tsx +216 -0
  69. package/template/components/checkout/order-summary.tsx +84 -0
  70. package/template/components/checkout/radio-card.tsx +34 -0
  71. package/template/components/checkout/step-indicator.tsx +56 -0
  72. package/template/components/checkout/steps.tsx +264 -0
  73. package/template/components/checkout/thank-you.tsx +48 -0
  74. package/template/components/checkout/use-checkout.ts +90 -0
  75. package/template/components/footer/site-footer.tsx +132 -0
  76. package/template/components/header/cart-button.tsx +43 -0
  77. package/template/components/header/desktop-nav.tsx +133 -0
  78. package/template/components/header/mobile-menu.tsx +102 -0
  79. package/template/components/header/site-header.tsx +78 -0
  80. package/template/components/page-loading.tsx +20 -0
  81. package/template/components/pager.tsx +3 -1
  82. package/template/components/product/buy-box.tsx +148 -0
  83. package/template/components/product/gallery.tsx +179 -0
  84. package/template/components/product/pre-order-form.tsx +93 -0
  85. package/template/components/product/product-selection.tsx +31 -0
  86. package/template/components/product/recommendations.tsx +26 -0
  87. package/template/components/product/review-list.tsx +159 -0
  88. package/template/components/product/reviews.tsx +80 -0
  89. package/template/components/product/variant-picker.tsx +75 -0
  90. package/template/components/product-card.tsx +103 -0
  91. package/template/components/product-grid.tsx +27 -57
  92. package/template/components/quick-add.tsx +62 -0
  93. package/template/components/search/predictive-search.tsx +282 -0
  94. package/template/components/sections/announcement-bar.tsx +1 -1
  95. package/template/components/sections/banner.tsx +1 -1
  96. package/template/components/sections/collections.tsx +50 -13
  97. package/template/components/sections/deal-of-day.tsx +2 -2
  98. package/template/components/sections/product-shelves.tsx +13 -5
  99. package/template/components/sections/shoppable-stories.tsx +2 -2
  100. package/template/components/sections/store-reviews.tsx +10 -4
  101. package/template/components/share-button.tsx +42 -0
  102. package/template/components/ui/accordion.tsx +35 -0
  103. package/template/components/ui/badge.tsx +20 -0
  104. package/template/components/ui/breadcrumbs.tsx +31 -0
  105. package/template/components/ui/button.tsx +90 -0
  106. package/template/components/ui/drawer.tsx +84 -0
  107. package/template/components/ui/empty-state.tsx +27 -0
  108. package/template/components/ui/field.tsx +33 -0
  109. package/template/components/ui/icon.tsx +112 -0
  110. package/template/components/ui/price.tsx +35 -0
  111. package/template/components/ui/quantity-stepper.tsx +91 -0
  112. package/template/components/ui/rating.tsx +48 -0
  113. package/template/components/ui/skeleton.tsx +46 -0
  114. package/template/components/wishlist/wishlist-view.tsx +134 -0
  115. package/template/lib/article-outline.ts +35 -0
  116. package/template/lib/catalog-params.ts +170 -0
  117. package/template/lib/contacts.ts +34 -0
  118. package/template/lib/errors.ts +29 -0
  119. package/template/lib/i18n.ts +484 -0
  120. package/template/lib/menu.ts +70 -0
  121. package/template/lib/theme-settings.ts +36 -0
  122. package/template/lib/upstream.ts +21 -0
  123. package/template/llms.txt +13 -0
  124. package/template/theme/index.ts +7 -1
  125. package/template/theme/sections/collapsible-content.tsx +75 -0
  126. package/template/theme/sections/collection-list.tsx +32 -4
  127. package/template/theme/sections/featured-collection.tsx +22 -2
  128. package/template/theme/sections/hero.tsx +94 -7
  129. package/template/theme/sections/image-with-text.tsx +19 -3
  130. package/template/theme/sections/multicolumn.tsx +113 -0
  131. package/template/theme/sections/page-content.tsx +10 -4
  132. package/template/theme/sections/product-shelf.tsx +26 -4
  133. package/template/theme/sections/rich-text.tsx +13 -1
  134. package/template/theme/sections/testimonials.tsx +14 -9
  135. package/template/theme/settings.ts +85 -1
  136. package/template/theme/templates/index.json +4 -2
  137. package/template/.screenshots/home-1280.png +0 -0
  138. package/template/.screenshots/home-390.png +0 -0
  139. package/template/.screenshots/home-filled-1280.png +0 -0
  140. package/template/.screenshots/home-filled-390.png +0 -0
  141. package/template/.screenshots/page-1280.png +0 -0
  142. package/template/components/buy-box.tsx +0 -89
  143. package/template/components/cart-link.tsx +0 -15
@@ -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
- <div className="stack">
9
- <h1>{t.notFound}</h1>
10
- <Link href="/">{t.backToShop}</Link>
11
- </div>
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
  }
@@ -18,7 +18,7 @@ export default async function Home() {
18
18
  preview,
19
19
  });
20
20
  return (
21
- <div className="home">
21
+ <div className="page-sections">
22
22
  <ThemeSections sections={sections} components={components} />
23
23
  </div>
24
24
  );
@@ -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="home">
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 { Image } from '@magicstoreai/hydrogen';
3
- import { jsonLdScript, pageMeta, productJsonLd } from '@magicstoreai/hydrogen/seo';
4
- import { BuyBox } from '@/components/buy-box';
5
- import { api, orNotFound } from '@/lib/api';
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
- <article className="product">
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(productJsonLd(product, { url: `/products/${product.handle}` })),
90
+ __html: jsonLdScript(
91
+ breadcrumbJsonLd(crumbs.map((crumb) => ({ name: crumb.name, url: crumb.href }))),
92
+ ),
35
93
  }}
36
94
  />
37
- <div className="stack">
38
- <Image data={product.featuredImage} alt={product.title} loading="eager" />
39
- </div>
40
- <div className="stack">
41
- <h1>{product.title}</h1>
42
- <BuyBox product={product} />
43
- {product.description && <div dangerouslySetInnerHTML={{ __html: product.description }} />}
44
- </div>
45
- </article>
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,23 +3,36 @@
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';
9
10
 
10
11
  /**
11
12
  * With a public storefront token the browser calls the shop's API directly (the token lists this
12
- * origin); without one, it goes through this app's same-origin proxy (app/storefront-api).
13
+ * origin); without one, it goes through this app's same-origin proxy (app/storefront-api), which by
14
+ * default also holds the buyer's credentials. `credentials` comes from the server
15
+ * (`credentialsMode()` in the layout), so the proxy and the provider always agree.
13
16
  */
14
- const connection = token
15
- ? { shopDomain: process.env.NEXT_PUBLIC_MAGICSTORE_SHOP_DOMAIN!, storefrontToken: token }
16
- : { baseUrl: '/storefront-api' };
17
-
18
- export function Providers({ shop, children }: { shop: Shop; children: ReactNode }) {
17
+ export function Providers({
18
+ shop,
19
+ credentials,
20
+ cartType,
21
+ children,
22
+ }: {
23
+ shop: Shop;
24
+ credentials: 'browser' | 'server';
25
+ /** The `cartType` theme setting: what adding to the cart does. */
26
+ cartType: CartType;
27
+ children: ReactNode;
28
+ }) {
29
+ const connection = token
30
+ ? { shopDomain: process.env.NEXT_PUBLIC_MAGICSTORE_SHOP_DOMAIN!, storefrontToken: token }
31
+ : { baseUrl: '/storefront-api', credentials };
19
32
  return (
20
33
  <MagicStoreProvider {...connection} shop={shop} locale={locale}>
21
34
  <PageViews />
22
- {children}
35
+ <CartDrawerProvider cartType={cartType}>{children}</CartDrawerProvider>
23
36
  </MagicStoreProvider>
24
37
  );
25
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 { Pager } from '@/components/pager';
4
- import { ProductGrid } from '@/components/product-grid';
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 { messages } from '@/lib/i18n';
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<{ q?: string; page?: string }> };
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 { q = '', page: pageParam } = await searchParams;
18
- const page = Math.max(1, Number(pageParam) || 1);
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
- <h1>{t.search}</h1>
25
- <form className="row" action="/search">
26
- <input name="q" defaultValue={q} placeholder={t.searchPlaceholder} />
27
- <button className="primary">{t.search}</button>
28
- </form>
29
- {results && (
30
- <>
31
- <SearchView query={q} resultsCount={results.meta.pagination.total} />
32
- <p className="muted">{t.found(results.meta.pagination.total)}</p>
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
  }
@@ -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 = [{ url: `${origin}/` }];
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) {
@@ -3,81 +3,26 @@
3
3
  * identifies the shop by the request's host and CORS admits only origins a token lists, so the
4
4
  * browser talks to this route and this route talks to the shop — with the shop's host, and the
5
5
  * buyer's address in X-Forwarded-For. Only the API's own headers pass, both ways.
6
+ *
7
+ * By default it also holds the buyer's credentials (`credentialsMode()`): session tokens and the
8
+ * cart and checkout ids live in httpOnly cookies, and the browser only ever sees "current".
6
9
  */
7
- import { upstreamBaseUrl } from '@/lib/upstream';
10
+ import { createStorefrontProxy, type StorefrontProxy } from '@magicstoreai/hydrogen/server';
8
11
 
9
- const upstream = upstreamBaseUrl();
12
+ import { credentialsMode, upstreamBaseUrl } from '@/lib/upstream';
10
13
 
11
- const REQUEST_HEADERS = [
12
- 'accept',
13
- 'accept-language',
14
- 'authorization',
15
- 'content-type',
16
- 'idempotency-key',
17
- 'if-none-match',
18
- 'x-anonymous-id',
19
- 'x-checkout-id',
20
- 'x-request-id',
21
- 'x-session-id',
22
- ];
14
+ let proxy: StorefrontProxy | null = null;
23
15
 
24
- const RESPONSE_HEADERS = [
25
- 'cache-control',
26
- 'content-language',
27
- 'content-type',
28
- 'etag',
29
- 'location',
30
- 'ratelimit-limit',
31
- 'ratelimit-remaining',
32
- 'ratelimit-reset',
33
- 'retry-after',
34
- 'x-request-id',
35
- ];
36
-
37
- async function proxy(
16
+ async function handle(
38
17
  request: Request,
39
18
  { params }: { params: Promise<{ path: string[] }> },
40
19
  ): Promise<Response> {
20
+ const upstream = upstreamBaseUrl();
41
21
  if (upstream === null) {
42
22
  return new Response('MAGICSTORE_SHOP_DOMAIN is not set', { status: 500 });
43
23
  }
44
- const { path } = await params;
45
- const incoming = new URL(request.url);
46
- const target = new URL(`${upstream}/${path.map(encodeURIComponent).join('/')}`);
47
- target.search = incoming.search;
48
-
49
- const headers = new Headers();
50
- for (const name of REQUEST_HEADERS) {
51
- const value = request.headers.get(name);
52
- if (value !== null) {
53
- headers.set(name, value);
54
- }
55
- }
56
- const client = request.headers.get('x-forwarded-for');
57
- if (client) {
58
- headers.set('x-forwarded-for', client);
59
- }
60
-
61
- const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
62
- const answer = await fetch(target, {
63
- method: request.method,
64
- headers,
65
- body: hasBody ? await request.arrayBuffer() : undefined,
66
- cache: 'no-store',
67
- redirect: 'manual',
68
- });
69
-
70
- const out = new Headers();
71
- for (const name of RESPONSE_HEADERS) {
72
- const value = answer.headers.get(name);
73
- if (value !== null) {
74
- out.set(name, value);
75
- }
76
- }
77
- return new Response(answer.status === 204 || answer.status === 304 ? null : answer.body, {
78
- status: answer.status,
79
- headers: out,
80
- });
24
+ proxy ??= createStorefrontProxy({ upstream, credentials: credentialsMode() });
25
+ return proxy(request, (await params).path);
81
26
  }
82
27
 
83
- export { proxy as DELETE, proxy as GET, proxy as PATCH, proxy as POST, proxy as PUT };
28
+ export { handle as DELETE, handle as GET, handle as PATCH, handle as POST, handle as PUT };