create-magic-storefront 0.1.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/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/index.js +222 -0
- package/package.json +31 -0
- package/template/.env.example +22 -0
- package/template/AGENTS.md +56 -0
- package/template/CLAUDE.md +1 -0
- package/template/PAGES.md +154 -0
- package/template/README.md +31 -0
- package/template/_gitignore +5 -0
- package/template/app/account/page.tsx +117 -0
- package/template/app/api/magicstore/webhook/route.ts +9 -0
- package/template/app/cart/page.tsx +124 -0
- package/template/app/checkout/page.tsx +268 -0
- package/template/app/collections/[handle]/page.tsx +48 -0
- package/template/app/error.tsx +18 -0
- package/template/app/globals.css +268 -0
- package/template/app/layout.tsx +63 -0
- package/template/app/not-found.tsx +10 -0
- package/template/app/page.tsx +16 -0
- package/template/app/pages/[handle]/page.tsx +28 -0
- package/template/app/products/[handle]/page.tsx +47 -0
- package/template/app/providers.tsx +49 -0
- package/template/app/search/page.tsx +37 -0
- package/template/app/sitemap.ts +36 -0
- package/template/app/storefront-api/[...path]/route.ts +83 -0
- package/template/components/analytics-views.tsx +18 -0
- package/template/components/buy-box.tsx +86 -0
- package/template/components/cart-link.tsx +9 -0
- package/template/components/pager.tsx +25 -0
- package/template/components/product-grid.tsx +32 -0
- package/template/components/sections/announcement-bar.tsx +13 -0
- package/template/components/sections/banner.tsx +41 -0
- package/template/components/sections/collections.tsx +67 -0
- package/template/components/sections/deal-of-day.tsx +48 -0
- package/template/components/sections/index.tsx +66 -0
- package/template/components/sections/product-shelves.tsx +123 -0
- package/template/components/sections/shoppable-stories.tsx +39 -0
- package/template/components/sections/store-reviews.tsx +67 -0
- package/template/components/sections/types.ts +10 -0
- package/template/lib/api.ts +35 -0
- package/template/lib/errors.ts +9 -0
- package/template/lib/upstream.ts +12 -0
- package/template/llms.txt +128 -0
- package/template/next.config.ts +9 -0
- package/template/package.json +26 -0
- package/template/tsconfig.json +33 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #fff;
|
|
3
|
+
--fg: #16161a;
|
|
4
|
+
--muted: #6b6b76;
|
|
5
|
+
--line: #e7e7ec;
|
|
6
|
+
--accent: #1f5eff;
|
|
7
|
+
--danger: #c62828;
|
|
8
|
+
font-family:
|
|
9
|
+
system-ui,
|
|
10
|
+
-apple-system,
|
|
11
|
+
'Segoe UI',
|
|
12
|
+
Roboto,
|
|
13
|
+
sans-serif;
|
|
14
|
+
color: var(--fg);
|
|
15
|
+
background: var(--bg);
|
|
16
|
+
}
|
|
17
|
+
* {
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
}
|
|
20
|
+
body {
|
|
21
|
+
margin: 0;
|
|
22
|
+
}
|
|
23
|
+
a {
|
|
24
|
+
color: inherit;
|
|
25
|
+
}
|
|
26
|
+
main {
|
|
27
|
+
max-width: 1100px;
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
padding: 24px 16px 64px;
|
|
30
|
+
}
|
|
31
|
+
header.site {
|
|
32
|
+
border-bottom: 1px solid var(--line);
|
|
33
|
+
}
|
|
34
|
+
header.site nav {
|
|
35
|
+
max-width: 1100px;
|
|
36
|
+
margin: 0 auto;
|
|
37
|
+
padding: 12px 16px;
|
|
38
|
+
display: flex;
|
|
39
|
+
gap: 16px;
|
|
40
|
+
align-items: center;
|
|
41
|
+
flex-wrap: wrap;
|
|
42
|
+
}
|
|
43
|
+
header.site .brand {
|
|
44
|
+
font-weight: 700;
|
|
45
|
+
text-decoration: none;
|
|
46
|
+
margin-right: auto;
|
|
47
|
+
}
|
|
48
|
+
.grid {
|
|
49
|
+
display: grid;
|
|
50
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
51
|
+
gap: 20px;
|
|
52
|
+
}
|
|
53
|
+
.card {
|
|
54
|
+
text-decoration: none;
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
gap: 6px;
|
|
58
|
+
}
|
|
59
|
+
.card img,
|
|
60
|
+
.card .noimg {
|
|
61
|
+
width: 100%;
|
|
62
|
+
aspect-ratio: 1;
|
|
63
|
+
object-fit: cover;
|
|
64
|
+
background: #f3f3f6;
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
}
|
|
67
|
+
.muted {
|
|
68
|
+
color: var(--muted);
|
|
69
|
+
}
|
|
70
|
+
.error {
|
|
71
|
+
color: var(--danger);
|
|
72
|
+
}
|
|
73
|
+
.row {
|
|
74
|
+
display: flex;
|
|
75
|
+
gap: 12px;
|
|
76
|
+
align-items: center;
|
|
77
|
+
flex-wrap: wrap;
|
|
78
|
+
}
|
|
79
|
+
.stack {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 12px;
|
|
83
|
+
}
|
|
84
|
+
.product {
|
|
85
|
+
display: grid;
|
|
86
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
87
|
+
gap: 32px;
|
|
88
|
+
}
|
|
89
|
+
@media (max-width: 720px) {
|
|
90
|
+
.product {
|
|
91
|
+
grid-template-columns: 1fr;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
.product img {
|
|
95
|
+
width: 100%;
|
|
96
|
+
height: auto;
|
|
97
|
+
border-radius: 8px;
|
|
98
|
+
}
|
|
99
|
+
button,
|
|
100
|
+
.button {
|
|
101
|
+
font: inherit;
|
|
102
|
+
padding: 10px 16px;
|
|
103
|
+
border-radius: 8px;
|
|
104
|
+
border: 1px solid var(--line);
|
|
105
|
+
background: #fff;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
text-decoration: none;
|
|
108
|
+
}
|
|
109
|
+
button.primary,
|
|
110
|
+
.button.primary {
|
|
111
|
+
background: var(--accent);
|
|
112
|
+
border-color: var(--accent);
|
|
113
|
+
color: #fff;
|
|
114
|
+
}
|
|
115
|
+
button[aria-pressed='true'] {
|
|
116
|
+
border-color: var(--fg);
|
|
117
|
+
}
|
|
118
|
+
button:disabled {
|
|
119
|
+
opacity: 0.45;
|
|
120
|
+
cursor: not-allowed;
|
|
121
|
+
}
|
|
122
|
+
input,
|
|
123
|
+
select {
|
|
124
|
+
font: inherit;
|
|
125
|
+
padding: 9px 12px;
|
|
126
|
+
border: 1px solid var(--line);
|
|
127
|
+
border-radius: 8px;
|
|
128
|
+
}
|
|
129
|
+
table {
|
|
130
|
+
width: 100%;
|
|
131
|
+
border-collapse: collapse;
|
|
132
|
+
}
|
|
133
|
+
td,
|
|
134
|
+
th {
|
|
135
|
+
padding: 10px 6px;
|
|
136
|
+
border-bottom: 1px solid var(--line);
|
|
137
|
+
text-align: left;
|
|
138
|
+
vertical-align: middle;
|
|
139
|
+
}
|
|
140
|
+
.pager {
|
|
141
|
+
display: flex;
|
|
142
|
+
gap: 8px;
|
|
143
|
+
margin-top: 24px;
|
|
144
|
+
}
|
|
145
|
+
fieldset {
|
|
146
|
+
border: 1px solid var(--line);
|
|
147
|
+
border-radius: 8px;
|
|
148
|
+
padding: 16px;
|
|
149
|
+
}
|
|
150
|
+
.home {
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-direction: column;
|
|
153
|
+
gap: 40px;
|
|
154
|
+
}
|
|
155
|
+
.announcement {
|
|
156
|
+
margin: 0;
|
|
157
|
+
padding: 10px 16px;
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
background: var(--fg);
|
|
160
|
+
color: var(--bg);
|
|
161
|
+
text-align: center;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
}
|
|
165
|
+
.announcement-ltr span,
|
|
166
|
+
.announcement-rtl span {
|
|
167
|
+
display: inline-block;
|
|
168
|
+
animation: marquee 18s linear infinite;
|
|
169
|
+
}
|
|
170
|
+
.announcement-rtl span {
|
|
171
|
+
animation-direction: reverse;
|
|
172
|
+
}
|
|
173
|
+
@keyframes marquee {
|
|
174
|
+
from {
|
|
175
|
+
transform: translateX(-100%);
|
|
176
|
+
}
|
|
177
|
+
to {
|
|
178
|
+
transform: translateX(100%);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
@media (prefers-reduced-motion: reduce) {
|
|
182
|
+
.announcement span {
|
|
183
|
+
animation: none;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
.banner {
|
|
187
|
+
display: grid;
|
|
188
|
+
grid-auto-flow: column;
|
|
189
|
+
grid-auto-columns: 100%;
|
|
190
|
+
overflow-x: auto;
|
|
191
|
+
scroll-snap-type: x mandatory;
|
|
192
|
+
gap: 16px;
|
|
193
|
+
}
|
|
194
|
+
.banner > * {
|
|
195
|
+
scroll-snap-align: start;
|
|
196
|
+
}
|
|
197
|
+
.banner img {
|
|
198
|
+
width: 100%;
|
|
199
|
+
height: auto;
|
|
200
|
+
border-radius: 8px;
|
|
201
|
+
}
|
|
202
|
+
.deal {
|
|
203
|
+
display: grid;
|
|
204
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
205
|
+
gap: 24px;
|
|
206
|
+
padding: 16px;
|
|
207
|
+
border: 2px solid var(--accent);
|
|
208
|
+
border-radius: 12px;
|
|
209
|
+
}
|
|
210
|
+
@media (max-width: 720px) {
|
|
211
|
+
.deal {
|
|
212
|
+
grid-template-columns: 1fr;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
.deal img {
|
|
216
|
+
width: 100%;
|
|
217
|
+
height: auto;
|
|
218
|
+
border-radius: 8px;
|
|
219
|
+
}
|
|
220
|
+
.badge {
|
|
221
|
+
align-self: flex-start;
|
|
222
|
+
padding: 2px 10px;
|
|
223
|
+
border-radius: 999px;
|
|
224
|
+
background: var(--accent);
|
|
225
|
+
color: #fff;
|
|
226
|
+
}
|
|
227
|
+
.stories {
|
|
228
|
+
display: flex;
|
|
229
|
+
gap: 16px;
|
|
230
|
+
overflow-x: auto;
|
|
231
|
+
}
|
|
232
|
+
.story {
|
|
233
|
+
display: flex;
|
|
234
|
+
flex-direction: column;
|
|
235
|
+
align-items: center;
|
|
236
|
+
gap: 6px;
|
|
237
|
+
text-decoration: none;
|
|
238
|
+
flex: 0 0 88px;
|
|
239
|
+
}
|
|
240
|
+
.story img,
|
|
241
|
+
.story .noimg {
|
|
242
|
+
width: 80px;
|
|
243
|
+
height: 80px;
|
|
244
|
+
object-fit: cover;
|
|
245
|
+
background: #f3f3f6;
|
|
246
|
+
border-radius: 12px;
|
|
247
|
+
}
|
|
248
|
+
.stories-circle .story img,
|
|
249
|
+
.stories-circle .story .noimg {
|
|
250
|
+
border-radius: 50%;
|
|
251
|
+
}
|
|
252
|
+
blockquote {
|
|
253
|
+
margin: 0;
|
|
254
|
+
padding: 16px;
|
|
255
|
+
border: 1px solid var(--line);
|
|
256
|
+
border-radius: 8px;
|
|
257
|
+
}
|
|
258
|
+
footer.site {
|
|
259
|
+
border-top: 1px solid var(--line);
|
|
260
|
+
}
|
|
261
|
+
footer.site nav {
|
|
262
|
+
max-width: 1100px;
|
|
263
|
+
margin: 0 auto;
|
|
264
|
+
padding: 16px;
|
|
265
|
+
display: flex;
|
|
266
|
+
gap: 16px;
|
|
267
|
+
flex-wrap: wrap;
|
|
268
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { CartLink } from '@/components/cart-link';
|
|
5
|
+
import { api, locale } from '@/lib/api';
|
|
6
|
+
import { Providers } from './providers';
|
|
7
|
+
import './globals.css';
|
|
8
|
+
|
|
9
|
+
// Rendered per request: the shop is the API's, not the build's. Public reads are still cached
|
|
10
|
+
// (lib/api.ts) and revalidated by the webhook.
|
|
11
|
+
export const dynamic = 'force-dynamic';
|
|
12
|
+
|
|
13
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
14
|
+
const { data: shop } = await api.shop();
|
|
15
|
+
return {
|
|
16
|
+
title: shop.seo.title || shop.name,
|
|
17
|
+
description: shop.seo.description ?? shop.description,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
22
|
+
const [{ data: shop }, { data: collections }, { data: pages }] = await Promise.all([
|
|
23
|
+
api.shop(),
|
|
24
|
+
api.collectionsIndex({ query: { perPage: 6 } }),
|
|
25
|
+
api.pagesIndex(),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<html lang={locale}>
|
|
30
|
+
<body>
|
|
31
|
+
<Providers shop={shop}>
|
|
32
|
+
<header className="site">
|
|
33
|
+
<nav>
|
|
34
|
+
<Link href="/" className="brand">
|
|
35
|
+
{shop.name}
|
|
36
|
+
</Link>
|
|
37
|
+
{collections.map((collection) => (
|
|
38
|
+
<Link key={collection.id} href={`/collections/${collection.handle}`}>
|
|
39
|
+
{collection.title}
|
|
40
|
+
</Link>
|
|
41
|
+
))}
|
|
42
|
+
<Link href="/search">Search</Link>
|
|
43
|
+
<Link href="/account">Account</Link>
|
|
44
|
+
<CartLink />
|
|
45
|
+
</nav>
|
|
46
|
+
</header>
|
|
47
|
+
<main>{children}</main>
|
|
48
|
+
{pages.length > 0 && (
|
|
49
|
+
<footer className="site">
|
|
50
|
+
<nav>
|
|
51
|
+
{pages.map((page) => (
|
|
52
|
+
<Link key={page.handle} href={`/pages/${page.handle}`}>
|
|
53
|
+
{page.title}
|
|
54
|
+
</Link>
|
|
55
|
+
))}
|
|
56
|
+
</nav>
|
|
57
|
+
</footer>
|
|
58
|
+
)}
|
|
59
|
+
</Providers>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HomeSections } from '@/components/sections';
|
|
2
|
+
import { api } from '@/lib/api';
|
|
3
|
+
|
|
4
|
+
export default async function Home() {
|
|
5
|
+
const { data: home } = await api.home();
|
|
6
|
+
// A shop that has not laid out its home page yet still greets buyers with its newest products.
|
|
7
|
+
const sections =
|
|
8
|
+
home.sections.length > 0
|
|
9
|
+
? home.sections
|
|
10
|
+
: [{ id: 'default', type: 'NEW_ARRIVALS' as const, settings: { limit: null } }];
|
|
11
|
+
return (
|
|
12
|
+
<div className="home">
|
|
13
|
+
<HomeSections sections={sections} />
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { pageMeta } from '@magicstoreai/hydrogen/seo';
|
|
3
|
+
import { api, orNotFound } from '@/lib/api';
|
|
4
|
+
|
|
5
|
+
type Props = { params: Promise<{ handle: string }> };
|
|
6
|
+
|
|
7
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
8
|
+
const { handle } = await params;
|
|
9
|
+
const { data: page } = await orNotFound(api.pagesShow({ path: { handle } }));
|
|
10
|
+
const meta = pageMeta({ seo: page.seo, title: page.title });
|
|
11
|
+
return { title: meta.title, description: meta.description };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A merchant's content page (about, delivery, offer…). `body` is the merchant's HTML. */
|
|
15
|
+
export default async function ContentPage({ params }: Props) {
|
|
16
|
+
const { handle } = await params;
|
|
17
|
+
const { data: page } = await orNotFound(api.pagesShow({ path: { handle } }));
|
|
18
|
+
return (
|
|
19
|
+
<article className="stack">
|
|
20
|
+
<h1>{page.title}</h1>
|
|
21
|
+
{page.body ? (
|
|
22
|
+
<div dangerouslySetInnerHTML={{ __html: page.body }} />
|
|
23
|
+
) : (
|
|
24
|
+
<p className="muted">Nothing here yet.</p>
|
|
25
|
+
)}
|
|
26
|
+
</article>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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';
|
|
6
|
+
|
|
7
|
+
type Props = { params: Promise<{ handle: string }> };
|
|
8
|
+
|
|
9
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
10
|
+
const { handle } = await params;
|
|
11
|
+
const { data: product } = await orNotFound(api.productsShow({ path: { handle } }));
|
|
12
|
+
const meta = pageMeta({
|
|
13
|
+
seo: product.seo,
|
|
14
|
+
title: product.title,
|
|
15
|
+
description: product.description,
|
|
16
|
+
images: product.images,
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
title: meta.title,
|
|
20
|
+
description: meta.description,
|
|
21
|
+
openGraph: { images: meta.openGraph.images },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function ProductPage({ params }: Props) {
|
|
26
|
+
const { handle } = await params;
|
|
27
|
+
const { data: product } = await orNotFound(api.productsShow({ path: { handle } }));
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<article className="product">
|
|
31
|
+
<script
|
|
32
|
+
type="application/ld+json"
|
|
33
|
+
dangerouslySetInnerHTML={{
|
|
34
|
+
__html: jsonLdScript(productJsonLd(product, { url: `/products/${product.handle}` })),
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
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>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { MagicStoreProvider, useAnalytics, type Shop } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { useEffect, useRef, type ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
const token = process.env.NEXT_PUBLIC_MAGICSTORE_STOREFRONT_TOKEN;
|
|
8
|
+
const locale = process.env.NEXT_PUBLIC_MAGICSTORE_LOCALE || 'ru';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 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
|
+
*/
|
|
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 }) {
|
|
19
|
+
return (
|
|
20
|
+
<MagicStoreProvider {...connection} shop={shop} locale={locale}>
|
|
21
|
+
<PageViews />
|
|
22
|
+
{children}
|
|
23
|
+
</MagicStoreProvider>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One PAGE_VIEW per route; the first carries the landing attribution. */
|
|
28
|
+
function PageViews() {
|
|
29
|
+
const analytics = useAnalytics();
|
|
30
|
+
const pathname = usePathname();
|
|
31
|
+
const first = useRef(true);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (first.current) {
|
|
35
|
+
first.current = false;
|
|
36
|
+
const url = new URL(window.location.href);
|
|
37
|
+
analytics.pageView(pathname, {
|
|
38
|
+
utmSource: url.searchParams.get('utm_source'),
|
|
39
|
+
utmMedium: url.searchParams.get('utm_medium'),
|
|
40
|
+
utmCampaign: url.searchParams.get('utm_campaign'),
|
|
41
|
+
referrer: document.referrer || null,
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
analytics.pageView(pathname);
|
|
46
|
+
}, [analytics, pathname]);
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { SearchView } from '@/components/analytics-views';
|
|
3
|
+
import { Pager } from '@/components/pager';
|
|
4
|
+
import { ProductGrid } from '@/components/product-grid';
|
|
5
|
+
import { api } from '@/lib/api';
|
|
6
|
+
|
|
7
|
+
type Props = { searchParams: Promise<{ q?: string; page?: string }> };
|
|
8
|
+
|
|
9
|
+
// Result pages are endless and change by the minute: keep them out of the index.
|
|
10
|
+
export const metadata: Metadata = { title: 'Search', robots: { index: false, follow: true } };
|
|
11
|
+
|
|
12
|
+
export default async function SearchPage({ searchParams }: Props) {
|
|
13
|
+
const { q = '', page: pageParam } = await searchParams;
|
|
14
|
+
const page = Math.max(1, Number(pageParam) || 1);
|
|
15
|
+
const results = q.trim() ? await api.search({ query: { q, page, perPage: 24 } }) : null;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<h1>Search</h1>
|
|
20
|
+
<form className="row" action="/search">
|
|
21
|
+
<input name="q" defaultValue={q} placeholder="What are you looking for?" />
|
|
22
|
+
<button className="primary">Search</button>
|
|
23
|
+
</form>
|
|
24
|
+
{results && (
|
|
25
|
+
<>
|
|
26
|
+
<SearchView query={q} resultsCount={results.meta.pagination.total} />
|
|
27
|
+
<p className="muted">{results.meta.pagination.total} found</p>
|
|
28
|
+
<ProductGrid products={results.data} />
|
|
29
|
+
<Pager
|
|
30
|
+
meta={results.meta.pagination}
|
|
31
|
+
href={(p) => `/search?q=${encodeURIComponent(q)}&page=${p}`}
|
|
32
|
+
/>
|
|
33
|
+
</>
|
|
34
|
+
)}
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
import { api } from '@/lib/api';
|
|
3
|
+
|
|
4
|
+
const PATHS = { PAGE: 'pages', COLLECTION: 'collections', PRODUCT: 'products' } as const;
|
|
5
|
+
/** The sitemap protocol allows 50 000 URLs in one file: 50 pages of `GET /sitemap`. */
|
|
6
|
+
const PER_PAGE = 1000;
|
|
7
|
+
const MAX_PAGES = 50;
|
|
8
|
+
|
|
9
|
+
// Built per request like every page: the shop is the API's, not the build's.
|
|
10
|
+
export const dynamic = 'force-dynamic';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every published product, collection and page (`GET /sitemap`, up to 1000 rows a page). URLs are
|
|
14
|
+
* absolute on `SITE_URL`, else the shop's primary domain.
|
|
15
|
+
*/
|
|
16
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
17
|
+
const { data: shop } = await api.shop();
|
|
18
|
+
const origin =
|
|
19
|
+
process.env.SITE_URL?.replace(/\/+$/, '') ??
|
|
20
|
+
`https://${shop.primaryDomain ?? process.env.MAGICSTORE_SHOP_DOMAIN}`;
|
|
21
|
+
|
|
22
|
+
const entries: MetadataRoute.Sitemap = [{ url: `${origin}/` }];
|
|
23
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
24
|
+
const { data, meta } = await api.sitemap({ query: { page, perPage: PER_PAGE } });
|
|
25
|
+
for (const row of data) {
|
|
26
|
+
entries.push({
|
|
27
|
+
url: `${origin}/${PATHS[row.type]}/${row.handle}`,
|
|
28
|
+
...(row.updatedAt && { lastModified: row.updatedAt }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (!meta.pagination.hasNextPage) {
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin proxy for browser calls when the site has no public storefront token. The API
|
|
3
|
+
* identifies the shop by the request's host and CORS admits only origins a token lists, so the
|
|
4
|
+
* browser talks to this route and this route talks to the shop — with the shop's host, and the
|
|
5
|
+
* buyer's address in X-Forwarded-For. Only the API's own headers pass, both ways.
|
|
6
|
+
*/
|
|
7
|
+
import { upstreamBaseUrl } from '@/lib/upstream';
|
|
8
|
+
|
|
9
|
+
const upstream = upstreamBaseUrl();
|
|
10
|
+
|
|
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
|
+
];
|
|
23
|
+
|
|
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(
|
|
38
|
+
request: Request,
|
|
39
|
+
{ params }: { params: Promise<{ path: string[] }> },
|
|
40
|
+
): Promise<Response> {
|
|
41
|
+
if (upstream === null) {
|
|
42
|
+
return new Response('MAGICSTORE_SHOP_DOMAIN is not set', { status: 500 });
|
|
43
|
+
}
|
|
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
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { proxy as DELETE, proxy as GET, proxy as PATCH, proxy as POST, proxy as PUT };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useAnalytics } from '@magicstoreai/hydrogen';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
/** Reports a collection view once per collection; renders nothing. */
|
|
7
|
+
export function CollectionView({ collectionId }: { collectionId: string }) {
|
|
8
|
+
const analytics = useAnalytics();
|
|
9
|
+
useEffect(() => analytics.collectionView(collectionId), [analytics, collectionId]);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Reports a search with how many results it found; renders nothing. */
|
|
14
|
+
export function SearchView({ query, resultsCount }: { query: string; resultsCount: number }) {
|
|
15
|
+
const analytics = useAnalytics();
|
|
16
|
+
useEffect(() => analytics.search(query, resultsCount), [analytics, query, resultsCount]);
|
|
17
|
+
return null;
|
|
18
|
+
}
|