├── .env.example
├── .github
└── dependabot.yml
├── .gitignore
├── .graphqlrc.yml
├── .prettierrc.cjs
├── .vscode
├── extensions.json
├── launch.json
└── settings.json
├── LICENSE
├── README.md
├── astro.config.mjs
├── package.json
├── pnpm-lock.yaml
├── public
└── favicon.svg
├── src
├── components
│ ├── AddToCartForm.svelte
│ ├── AnnouncementBar.astro
│ ├── CartDrawer.svelte
│ ├── CartIcon.svelte
│ ├── Footer.astro
│ ├── Header.astro
│ ├── Logo.astro
│ ├── Money.svelte
│ ├── ProductAccordions.astro
│ ├── ProductBreadcrumb.astro
│ ├── ProductCard.astro
│ ├── ProductImageGallery.astro
│ ├── ProductInformations.astro
│ ├── ProductRecommendations.astro
│ ├── ProductReviews.astro
│ ├── Products.astro
│ └── ShopifyImage.svelte
├── env.d.ts
├── layouts
│ ├── BaseLayout.astro
│ └── NotFoundLayout.astro
├── pages
│ ├── 404.astro
│ ├── index.astro
│ └── products
│ │ └── [...handle].astro
├── stores
│ └── cart.ts
├── styles
│ └── global.css
└── utils
│ ├── cache.ts
│ ├── click-outside.ts
│ ├── config.ts
│ ├── graphql.ts
│ ├── schemas.ts
│ └── shopify.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | PUBLIC_SHOPIFY_SHOP={your-store}.myshopify.com
2 | PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
3 | PRIVATE_SHOPIFY_STOREFRONT_ACCESS_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'npm'
4 | directory: '/'
5 | reviewers:
6 | - 'thomasKn'
7 | schedule:
8 | interval: 'weekly'
9 | time: '05:30'
10 | timezone: 'America/New_York'
11 | groups:
12 | patch-minor:
13 | applies-to: version-updates
14 | update-types:
15 | - 'minor'
16 | - 'patch'
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 | .vercel/
4 | .astro/
5 |
6 | # dependencies
7 | node_modules/
8 |
9 | # logs
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
--------------------------------------------------------------------------------
/.graphqlrc.yml:
--------------------------------------------------------------------------------
1 | schema: node_modules/@shopify/hydrogen-react/storefront.schema.json
2 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
3 | overrides: [
4 | {
5 | files: "*.astro",
6 | options: {
7 | parser: "astro",
8 | },
9 | },
10 | ],
11 | };
12 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "command": "./node_modules/.bin/astro dev",
6 | "name": "Development server",
7 | "request": "launch",
8 | "type": "node-terminal"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.validate.enable": true
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Thomas Cristina de Carvalho
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Astro starter theme to build a headless ecommerce website with Shopify
2 |
3 | The theme is built with Svelte but you can use any framework you like (React, Vue, Solid etc.) thanks to Astro.
4 | Tailwind UI free components are used for the design.
5 |
6 | 
7 |
8 | ## 🧑🚀 Where to start
9 |
10 | 1. Create a `.env` file based on `.env.example` with your Shopify store url and your public and private access tokens
11 | 2. The credentials are used inside the `/utils/config.ts` file, you can update the API version there
12 | 3. Run `npm install` or `yarn` or `pnpm install`
13 | 4. Run `npm run dev` or `yarn run dev` or `pnpm run dev`
14 |
15 | ## Shopify Configuration Guide
16 |
17 | - Create a new account or use an existing one. https://accounts.shopify.com/store-login
18 | - Add the [Shopify Headless channel](https://apps.shopify.com/headless) to your store
19 | - Click on `Add Storefront`
20 | - Copy/Paste your `public` and `private` access tokens to your .env file
21 | - Next, check Storefront API access scopes
22 | - `unauthenticated_read_product_listings` and `unauthenticated_read_product_inventory` access should be fine to get you started.
23 | - Add more scopes if you require additional permissions.
24 |
25 | ### Shopify Troubleshooting
26 |
27 | - If you encounter an error like `error code 401` you likely didn't set this up correctly. Revisit your scopes and be sure add at least one test product. Also make sure you are using the `Storefront API` and not the `Admin API` as the endpoints and scopes are different.
28 | - If you do not see a checkout sidebar, or if it is empty after adding a product, you need to add an image to your test product.
29 |
30 | ## 🚀 Project Structure
31 |
32 | Inside the project, you'll see the following folders and files:
33 |
34 | ```
35 | /
36 | ├── public/
37 | ├── src/
38 | │ └── components/
39 | │ └── Header.astro
40 | │ └── layouts/
41 | │ └── BaseLayout.astro
42 | │ └── pages/
43 | │ └── index.astro
44 | │ └── stores/
45 | │ └── cart.ts
46 | │ └── styles/
47 | │ └── global.css
48 | │ └── utils/
49 | │ └── shopify.ts
50 | └── package.json
51 | ```
52 |
53 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
54 |
55 | There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
56 |
57 | Any static assets, like images, can be placed in the `public/` directory.
58 |
59 | ## 🧞 Commands
60 |
61 | All commands are run from the root of the project, from a terminal:
62 |
63 | | Command | Action |
64 | | :--------------------- | :----------------------------------------------- |
65 | | `npm install` | Installs dependencies |
66 | | `npm run dev` | Starts local dev server at `localhost:3000` |
67 | | `npm run build` | Build your production site to `./dist/` |
68 | | `npm run preview` | Preview your build locally, before deploying |
69 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
70 | | `npm run astro --help` | Get help using the Astro CLI |
71 |
72 | ## ⚡️ Lighthouse
73 | 
74 |
--------------------------------------------------------------------------------
/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "astro/config";
2 | import vercel from "@astrojs/vercel";
3 | import tailwindcss from "@tailwindcss/vite";
4 |
5 | // https://astro.build/config
6 | import svelte from "@astrojs/svelte";
7 |
8 | // https://astro.build/config
9 | export default defineConfig({
10 | output: "server",
11 | adapter: vercel(),
12 |
13 | integrations: [svelte()],
14 |
15 | vite: {
16 | plugins: [tailwindcss()],
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@example/minimal",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "private": true,
6 | "scripts": {
7 | "dev": "astro dev",
8 | "start": "astro dev",
9 | "build": "astro build",
10 | "preview": "astro preview",
11 | "astro": "astro",
12 | "typecheck": "tsc --noEmit"
13 | },
14 | "dependencies": {
15 | "@astrojs/svelte": "^7.1.0",
16 | "@astrojs/vercel": "^8.1.5",
17 | "@nanostores/persistent": "^1.0.0",
18 | "@shopify/hydrogen-react": "^2025.5.0",
19 | "@tailwindcss/vite": "^4.1.8",
20 | "astro": "^5.9.1",
21 | "nanostores": "^1.0.1",
22 | "svelte": "^5.33.18",
23 | "zod": "^3.25.56"
24 | },
25 | "devDependencies": {
26 | "prettier": "^3.5.3",
27 | "prettier-plugin-astro": "^0.14.1",
28 | "prettier-plugin-tailwindcss": "^0.6.12",
29 | "tailwindcss": "^4.1.8",
30 | "typescript": "^5.8.3"
31 | },
32 | "engines": {
33 | "node": ">=20"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
13 |
14 |
--------------------------------------------------------------------------------
/src/components/AddToCartForm.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
76 |
--------------------------------------------------------------------------------
/src/components/AnnouncementBar.astro:
--------------------------------------------------------------------------------
1 | ---
2 | const message = "🎄 Free delivery for Christmas 🎁";
3 | ---
4 |
5 |
8 | {message}
9 |
10 |
--------------------------------------------------------------------------------
/src/components/CartDrawer.svelte:
--------------------------------------------------------------------------------
1 |
45 |
46 | {#if $isCartDrawerOpen}
47 |
53 |
58 |
59 |
60 |
61 |
closeCartDrawer()}
65 | bind:this={cartDrawerEl}
66 | onkeydown={onKeyDown}
67 | >
68 |
73 |
74 |
75 |
79 | Your cart
80 | {#if $isCartUpdating}
81 |
87 |
95 |
100 |
101 | {/if}
102 |
103 |
104 |
closeCartDrawer()}
106 | type="button"
107 | class="-m-2 p-2 text-gray-400 hover:text-gray-500"
108 | >
109 | Close panel
110 |
111 |
120 |
125 |
126 |
127 |
128 |
129 |
130 |
211 |
212 |
213 | {#if $cart && $cart.lines?.nodes.length > 0}
214 |
215 |
218 |
Subtotal
219 |
220 |
224 |
225 |
226 |
227 | Shipping and taxes calculated at checkout.
228 |
229 |
232 |
233 | {/if}
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 | {/if}
242 |
--------------------------------------------------------------------------------
/src/components/CartIcon.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
openCart()}>
16 | Open your cart
17 |
25 |
30 |
31 | {#if $cart && $cart.totalQuantity > 0}
32 |
35 |
36 | {$cart.totalQuantity}
37 |
38 |
39 | {/if}
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/components/Footer.astro:
--------------------------------------------------------------------------------
1 |
165 |
--------------------------------------------------------------------------------
/src/components/Header.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Logo from "./Logo.astro";
3 | import CartIcon from "./CartIcon.svelte";
4 | ---
5 |
6 |
24 |
--------------------------------------------------------------------------------
/src/components/Logo.astro:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
13 |
17 |
21 |
22 |
23 |
27 |
31 |
35 |
39 |
43 |
47 |
51 |
55 |
59 |
63 |
67 |
71 |
75 |
79 |
84 |
89 |
90 |
94 |
98 |
102 |
106 |
110 |
114 |
115 |
116 |
120 |
124 |
128 |
132 |
136 |
141 |
146 |
147 |
148 |
152 |
156 |
157 |
158 |
159 |
165 |
171 |
177 |
178 |
179 |
180 |
188 |
189 |
190 |
191 |
199 |
200 |
201 |
202 |
210 |
211 |
212 |
213 |
221 |
222 |
223 |
224 |
232 |
233 |
234 |
235 |
243 |
244 |
245 |
246 |
254 |
255 |
256 |
257 |
265 |
266 |
267 |
268 |
276 |
277 |
278 |
279 |
287 |
288 |
289 |
290 |
298 |
299 |
300 |
301 |
309 |
310 |
311 |
312 |
320 |
321 |
322 |
323 |
324 |
325 |
333 |
334 |
335 |
336 |
337 |
338 |
346 |
347 |
348 |
349 |
357 |
358 |
359 |
360 |
368 |
369 |
370 |
371 |
379 |
380 |
381 |
382 |
390 |
391 |
392 |
393 |
401 |
402 |
403 |
404 |
412 |
413 |
414 |
415 |
423 |
424 |
425 |
426 |
434 |
435 |
436 |
437 |
438 |
439 |
447 |
448 |
449 |
450 |
451 |
452 |
460 |
461 |
462 |
463 |
471 |
472 |
473 |
474 |
482 |
483 |
484 |
485 |
493 |
494 |
495 |
496 |
497 |
498 |
--------------------------------------------------------------------------------
/src/components/Money.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 | {formatPrice}
21 |
22 |
--------------------------------------------------------------------------------
/src/components/ProductAccordions.astro:
--------------------------------------------------------------------------------
1 | ---
2 | const accordions = [
3 | {
4 | title: "Shipping",
5 | icon: "truck",
6 | content:
7 | "Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam possimus fuga dolor rerum dicta, ipsum laboriosam est totam iusto alias incidunt cum tempore aliquid aliquam error quisquam ipsam asperiores! Iste?",
8 | },
9 | {
10 | title: "Care instructions",
11 | icon: "care",
12 | content:
13 | "Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam possimus fuga dolor rerum dicta, ipsum laboriosam est totam iusto alias incidunt cum tempore aliquid aliquam error quisquam ipsam asperiores! Iste?",
14 | },
15 | ];
16 | ---
17 |
18 |
19 | {
20 | accordions.map((accordion, index) => (
21 |
28 |
29 |
30 | {accordion.icon === "truck" && (
31 |
39 |
44 |
45 | )}
46 | {accordion.icon === "care" && (
47 |
55 |
60 |
61 | )}
62 | {accordion.title}
63 |
64 |
65 |
73 |
78 |
79 |
80 |
81 | {accordion.content}
82 |
83 | ))
84 | }
85 |
86 |
--------------------------------------------------------------------------------
/src/components/ProductBreadcrumb.astro:
--------------------------------------------------------------------------------
1 | ---
2 | export interface Props {
3 | title: string;
4 | }
5 | const { title } = Astro.props as Props;
6 | ---
7 |
8 |
11 |
12 |
13 |
14 |
15 | Home
16 |
17 |
24 |
30 |
31 |
32 |
33 |
34 |
35 |
41 |
45 |
46 |
47 |
48 |
49 | {title}
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/components/ProductCard.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import type { z } from "zod";
3 | import type { ProductResult } from "../utils/schemas";
4 |
5 | import ShopifyImage from "./ShopifyImage.svelte";
6 | import Money from "./Money.svelte";
7 |
8 | export interface Props {
9 | product: z.infer;
10 | }
11 | const { product } = Astro.props as Props;
12 | ---
13 |
14 |
18 |
19 |
32 |
35 |
36 |
37 |
45 |
50 |
51 |
52 | Shop now
53 |
54 |
55 |
56 |
57 |
{product?.title}
58 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/src/components/ProductImageGallery.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { z } from "zod";
3 | import { ImageResult } from "../utils/schemas";
4 | import ShopifyImage from "./ShopifyImage.svelte";
5 |
6 | const ImagesResult = z.object({
7 | nodes: z.array(ImageResult),
8 | });
9 |
10 | export interface Props {
11 | images: z.infer;
12 | }
13 | const { images } = Astro.props as Props;
14 | ---
15 |
16 |
17 |
18 |
31 |
32 |
2 },
43 | ]}
44 | >
45 | {
46 | images.nodes.map((image, index) => {
47 | if (index < 3) {
48 | return (
49 |
50 |
60 |
61 | );
62 | }
63 | })
64 | }
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/components/ProductInformations.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import type { z } from "zod";
3 | import type { MoneyV2Result } from "../utils/schemas";
4 | import Money from "./Money.svelte";
5 |
6 | export interface Props {
7 | price?: z.infer;
8 | title: string;
9 | }
10 | const { price, title } = Astro.props as Props;
11 | ---
12 |
13 |
14 | {title}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
30 |
33 |
34 |
40 |
43 |
44 |
50 |
53 |
54 |
60 |
63 |
64 |
70 |
73 |
74 |
75 |
42 Reviews
78 |
79 |
80 |
81 | This store is for demo purposes only. No orders will be fulfilled. Please
82 | visit the brand's website to purchase this product.
88 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/src/components/ProductRecommendations.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getProductRecommendations } from "../utils/shopify";
3 | import ProductCard from "./ProductCard.astro";
4 | export interface Props {
5 | productId: string;
6 | buyerIP: string;
7 | }
8 |
9 | const { productId, buyerIP } = Astro.props as Props;
10 |
11 | const productRecommendations = await getProductRecommendations({
12 | productId,
13 | buyerIP,
14 | });
15 | ---
16 |
17 | {
18 | productRecommendations.length > 0 && (
19 |
20 |
21 |
22 | Customers also purchased
23 |
24 |
25 |
26 | {productRecommendations.map((product) => (
27 |
28 | ))}
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ProductReviews.astro:
--------------------------------------------------------------------------------
1 |
4 |
349 |
--------------------------------------------------------------------------------
/src/components/Products.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { z } from "zod";
3 | import { ProductResult } from "../utils/schemas";
4 | import ProductCard from "./ProductCard.astro";
5 |
6 | const ProductsResult = z.array(ProductResult);
7 | export interface Props {
8 | products: z.infer;
9 | }
10 | const { products } = Astro.props as Props;
11 | ---
12 |
13 |
14 |
15 |
Products
16 |
17 |
18 | {products.map((product) =>
)}
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/components/ShopifyImage.svelte:
--------------------------------------------------------------------------------
1 |
31 |
32 | {#if image}
33 | image && value < image.width)
43 | .map((value) => {
44 | if (image && image.width >= value) {
45 | return `${imageFilter({
46 | width: value,
47 | })} ${value}w`;
48 | }
49 | })
50 | .join(", ")
51 | .concat(`, ${image.url} ${image.width}w`)}
52 | />
53 | {:else}
54 |
55 |
60 |
63 |
66 |
69 |
72 |
73 | {/if}
74 |
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/layouts/BaseLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Header from "../components/Header.astro";
3 | import Footer from "../components/Footer.astro";
4 | import CartDrawer from "../components/CartDrawer.svelte";
5 | import AnnouncementBar from "../components/AnnouncementBar.astro";
6 |
7 | export interface Props {
8 | title: string;
9 | description?: string;
10 | }
11 |
12 | const defaultDesc =
13 | "A lightweight and minimalit Astro starter theme using Shopify with Svelte, Tailwind, and TypeScript.";
14 |
15 | const { title, description = defaultDesc } = Astro.props as Props;
16 | ---
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {title}
25 |
26 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/layouts/NotFoundLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import BaseLayout from "./BaseLayout.astro";
3 |
4 | export interface Props {
5 | title?: string;
6 | message?: string;
7 | }
8 |
9 | Astro.response.status = 404;
10 |
11 | const defaultTitle = "Page not found";
12 |
13 | const { title = defaultTitle, message = defaultTitle } = Astro.props as Props;
14 | ---
15 |
16 |
17 |
18 |
404
19 |
{message}
20 |
24 | Go back home
25 | →
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/pages/404.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import NotFoundLayout from "../layouts/NotFoundLayout.astro";
3 |
4 | Astro.response.status = 404;
5 | ---
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getProducts } from "./../utils/shopify";
3 | import BaseLayout from "../layouts/BaseLayout.astro";
4 | import Products from "../components/Products.astro";
5 | import { setCache } from "../utils/cache";
6 |
7 | const title = "Astro + Shopify";
8 | const headers = Astro.request.headers;
9 | const ip = headers.get("x-vercel-forwarded-for") || Astro.clientAddress;
10 | const products = await getProducts({ buyerIP: ip });
11 |
12 | setCache.short(Astro);
13 | ---
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/pages/products/[...handle].astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getProductByHandle } from "./../../utils/shopify";
3 | import { setCache } from "../../utils/cache";
4 |
5 | import BaseLayout from "../../layouts/BaseLayout.astro";
6 | import NotFoundLayout from "../../layouts/NotFoundLayout.astro";
7 | import AddToCartForm from "../../components/AddToCartForm.svelte";
8 | import ProductImageGallery from "../../components/ProductImageGallery.astro";
9 | import ProductBreadcrumb from "../../components/ProductBreadcrumb.astro";
10 | import ProductInformations from "../../components/ProductInformations.astro";
11 | import ProductRecommendations from "../../components/ProductRecommendations.astro";
12 | import ProductReviews from "../../components/ProductReviews.astro";
13 | import ProductAccordions from "../../components/ProductAccordions.astro";
14 |
15 | const { handle } = Astro.params;
16 | const headers = Astro.request.headers;
17 | const ip = headers.get("x-vercel-forwarded-for") || Astro.clientAddress;
18 | const product = await getProductByHandle({ handle: handle || "", buyerIP: ip });
19 |
20 | if (!product) {
21 | Astro.response.status = 404;
22 | }
23 |
24 | const firstVariant = product?.variants.nodes[0];
25 | setCache.short(Astro);
26 | ---
27 |
28 | {
29 | !product ? (
30 |
31 | ) : (
32 |
33 |
36 |
37 |
38 |
39 |
42 |
43 |
44 |
48 |
49 |
57 |
58 |
61 |
62 |
63 |
64 |
65 |
68 |
69 |
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/stores/cart.ts:
--------------------------------------------------------------------------------
1 | import type { z } from "zod";
2 | import { atom } from "nanostores";
3 | import { persistentAtom } from "@nanostores/persistent";
4 | import {
5 | getCart,
6 | addCartLines,
7 | createCart,
8 | removeCartLines,
9 | } from "../utils/shopify";
10 | import type { CartResult } from "../utils/schemas";
11 |
12 | // Cart drawer state (open or closed) with initial value (false) and no persistent state (local storage)
13 | export const isCartDrawerOpen = atom(false);
14 |
15 | // Cart is updating state (true or false) with initial value (false) and no persistent state (local storage)
16 | export const isCartUpdating = atom(false);
17 |
18 | const emptyCart = {
19 | id: "",
20 | checkoutUrl: "",
21 | totalQuantity: 0,
22 | lines: { nodes: [] },
23 | cost: { subtotalAmount: { amount: "", currencyCode: "" } },
24 | };
25 |
26 | // Cart store with persistent state (local storage) and initial value
27 | export const cart = persistentAtom>(
28 | "cart",
29 | emptyCart,
30 | {
31 | encode: JSON.stringify,
32 | decode: JSON.parse,
33 | }
34 | );
35 |
36 | // Fetch cart data if a cart exists in local storage, this is called during session start only
37 | // This is useful to validate if the cart still exists in Shopify and if it's not empty
38 | // Shopify automatically deletes the cart when the customer completes the checkout or if the cart is unused or abandoned after 10 days
39 | // https://shopify.dev/custom-storefronts/cart#considerations
40 | export async function initCart() {
41 | const sessionStarted = sessionStorage.getItem("sessionStarted");
42 | if (!sessionStarted) {
43 | sessionStorage.setItem("sessionStarted", "true");
44 | const localCart = cart.get();
45 | const cartId = localCart?.id;
46 | if (cartId) {
47 | const data = await getCart(cartId);
48 |
49 | if (data) {
50 | cart.set({
51 | id: data.id,
52 | cost: data.cost,
53 | checkoutUrl: data.checkoutUrl,
54 | totalQuantity: data.totalQuantity,
55 | lines: data.lines,
56 | });
57 | } else {
58 | // If the cart doesn't exist in Shopify, reset the cart store
59 | cart.set(emptyCart);
60 | }
61 | }
62 | }
63 | }
64 |
65 | // Add item to cart or create a new cart if it doesn't exist yet
66 | export async function addCartItem(item: { id: string; quantity: number }) {
67 | const localCart = cart.get();
68 | const cartId = localCart?.id;
69 |
70 | isCartUpdating.set(true);
71 |
72 | if (!cartId) {
73 | const cartData = await createCart(item.id, item.quantity);
74 |
75 | if (cartData) {
76 | cart.set({
77 | ...cart.get(),
78 | id: cartData.id,
79 | cost: cartData.cost,
80 | checkoutUrl: cartData.checkoutUrl,
81 | totalQuantity: cartData.totalQuantity,
82 | lines: cartData.lines,
83 | });
84 | isCartUpdating.set(false);
85 | isCartDrawerOpen.set(true);
86 | }
87 | } else {
88 | const cartData = await addCartLines(cartId, item.id, item.quantity);
89 |
90 | if (cartData) {
91 | cart.set({
92 | ...cart.get(),
93 | id: cartData.id,
94 | cost: cartData.cost,
95 | checkoutUrl: cartData.checkoutUrl,
96 | totalQuantity: cartData.totalQuantity,
97 | lines: cartData.lines,
98 | });
99 | isCartUpdating.set(false);
100 | isCartDrawerOpen.set(true);
101 | }
102 | }
103 | }
104 |
105 | export async function removeCartItems(lineIds: string[]) {
106 | const localCart = cart.get();
107 | const cartId = localCart?.id;
108 |
109 | isCartUpdating.set(true);
110 |
111 | if (cartId) {
112 | const cartData = await removeCartLines(cartId, lineIds);
113 |
114 | if (cartData) {
115 | cart.set({
116 | ...cart.get(),
117 | id: cartData.id,
118 | cost: cartData.cost,
119 | checkoutUrl: cartData.checkoutUrl,
120 | totalQuantity: cartData.totalQuantity,
121 | lines: cartData.lines,
122 | });
123 | isCartUpdating.set(false);
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @theme {
4 | /* Animation */
5 | --animate-shake: shake 0.5s infinite;
6 |
7 | @keyframes shake {
8 | 0% {
9 | transform: translate(1px, 1px) rotate(0deg);
10 | }
11 | 10% {
12 | transform: translate(-1px, -2px) rotate(-1deg);
13 | }
14 | 20% {
15 | transform: translate(-3px, 0px) rotate(1deg);
16 | }
17 | 30% {
18 | transform: translate(3px, 2px) rotate(0deg);
19 | }
20 | 40% {
21 | transform: translate(1px, -1px) rotate(1deg);
22 | }
23 | 50% {
24 | transform: translate(-1px, 2px) rotate(-1deg);
25 | }
26 | 60% {
27 | transform: translate(-3px, 1px) rotate(0deg);
28 | }
29 | 70% {
30 | transform: translate(3px, 1px) rotate(-1deg);
31 | }
32 | 80% {
33 | transform: translate(-1px, -1px) rotate(1deg);
34 | }
35 | 90% {
36 | transform: translate(1px, 2px) rotate(0deg);
37 | }
38 | 100% {
39 | transform: translate(1px, -2px) rotate(-1deg);
40 | }
41 | }
42 | }
43 |
44 | @utility container {
45 | margin-inline: auto;
46 | padding-inline: 1.5rem;
47 | }
48 |
49 | @layer base {
50 | html {
51 | @apply scroll-smooth;
52 | }
53 |
54 | body {
55 | @apply flex min-h-screen flex-col;
56 | }
57 |
58 | main {
59 | @apply flex-1;
60 | }
61 |
62 | details > summary {
63 | list-style: none;
64 | }
65 | details > summary::-webkit-details-marker {
66 | display: none;
67 | }
68 |
69 | *,
70 | :before,
71 | :after {
72 | @apply border-gray-200;
73 | }
74 |
75 | .button {
76 | @apply flex items-center justify-center rounded-lg;
77 | @apply bg-gradient-to-br from-emerald-900 via-emerald-800 to-emerald-600;
78 | @apply px-8 py-3 text-base font-medium text-white;
79 | @apply transition hover:opacity-80;
80 | @apply focus:ring-2 focus:ring-black focus:ring-offset-1 focus:outline-none;
81 | @apply disabled:cursor-not-allowed disabled:opacity-60;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/utils/cache.ts:
--------------------------------------------------------------------------------
1 | import type { AstroGlobal } from "astro";
2 |
3 | export const setCache = {
4 | long: (Astro: AstroGlobal) => {
5 | Astro.response.headers.set(
6 | "Cache-Control",
7 | "public, max-age=60, stale-while-revalidate=120"
8 | );
9 | },
10 |
11 | short: (Astro: AstroGlobal) => {
12 | Astro.response.headers.set(
13 | "Cache-Control",
14 | "public, max-age=1, stale-while-revalidate=9"
15 | );
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/utils/click-outside.ts:
--------------------------------------------------------------------------------
1 | export function clickOutside(element: HTMLElement, callback: () => void) {
2 | function handleClick(event: MouseEvent) {
3 | if (!element.contains(event.target as Node)) {
4 | callback();
5 | }
6 | }
7 |
8 | document.addEventListener("click", handleClick, true);
9 |
10 | return {
11 | destroy() {
12 | document.removeEventListener("click", handleClick, true);
13 | },
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/config.ts:
--------------------------------------------------------------------------------
1 | import { configSchema } from "./schemas";
2 |
3 | const defineConfig = {
4 | shopifyShop: import.meta.env.PUBLIC_SHOPIFY_SHOP,
5 | publicShopifyAccessToken: import.meta.env
6 | .PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN,
7 | privateShopifyAccessToken: import.meta.env
8 | .PRIVATE_SHOPIFY_STOREFRONT_ACCESS_TOKEN
9 | ? import.meta.env.PRIVATE_SHOPIFY_STOREFRONT_ACCESS_TOKEN
10 | : "",
11 | apiVersion: "2023-01",
12 | };
13 |
14 | export const config = configSchema.parse(defineConfig);
15 |
--------------------------------------------------------------------------------
/src/utils/graphql.ts:
--------------------------------------------------------------------------------
1 | const CART_FRAGMENT = `#graphql
2 | fragment cartFragment on Cart {
3 | id
4 | totalQuantity
5 | checkoutUrl
6 | cost {
7 | subtotalAmount {
8 | amount
9 | currencyCode
10 | }
11 | }
12 | lines(first: 100) {
13 | nodes {
14 | id
15 | quantity
16 | merchandise {
17 | ...on ProductVariant {
18 | id
19 | title
20 | image {
21 | url
22 | altText
23 | width
24 | height
25 | }
26 | product {
27 | handle
28 | title
29 | }
30 | }
31 | }
32 | cost {
33 | amountPerQuantity{
34 | amount
35 | currencyCode
36 | }
37 | subtotalAmount {
38 | amount
39 | currencyCode
40 | }
41 | totalAmount {
42 | amount
43 | currencyCode
44 | }
45 | }
46 | }
47 | }
48 | }
49 | `;
50 |
51 | const PRODUCT_FRAGMENT = `#graphql
52 | fragment productFragment on Product {
53 | id
54 | title
55 | handle
56 | images (first: 10) {
57 | nodes {
58 | url
59 | width
60 | height
61 | altText
62 | }
63 | }
64 | variants(first: 10) {
65 | nodes {
66 | id
67 | title
68 | availableForSale
69 | quantityAvailable
70 | price {
71 | amount
72 | currencyCode
73 | }
74 | }
75 | }
76 | featuredImage {
77 | url
78 | width
79 | height
80 | altText
81 | }
82 | }
83 | `;
84 |
85 | export const ProductsQuery = `#graphql
86 | query ($first: Int!) {
87 | products(first: $first) {
88 | edges {
89 | node {
90 | ...productFragment
91 | }
92 | }
93 | }
94 | }
95 | ${PRODUCT_FRAGMENT}
96 | `;
97 |
98 | export const ProductByHandleQuery = `#graphql
99 | query ($handle: String!) {
100 | product(handle: $handle) {
101 | ...productFragment
102 | }
103 | }
104 | ${PRODUCT_FRAGMENT}
105 | `;
106 |
107 | export const ProductRecommendationsQuery = `#graphql
108 | query ($productId: ID!) {
109 | productRecommendations(productId: $productId) {
110 | ...productFragment
111 | }
112 | }
113 | ${PRODUCT_FRAGMENT}
114 | `;
115 |
116 | export const GetCartQuery = `#graphql
117 | query ($id: ID!) {
118 | cart(id: $id) {
119 | ...cartFragment
120 | }
121 | }
122 | ${CART_FRAGMENT}
123 | `;
124 |
125 | export const CreateCartMutation = `#graphql
126 | mutation ($id: ID!, $quantity: Int!) {
127 | cartCreate (input: { lines: [{ merchandiseId: $id, quantity: $quantity }] }) {
128 | cart {
129 | ...cartFragment
130 | }
131 | userErrors {
132 | field
133 | message
134 | }
135 | }
136 | }
137 | ${CART_FRAGMENT}
138 | `;
139 |
140 | export const AddCartLinesMutation = `#graphql
141 | mutation ($cartId: ID!, $merchandiseId: ID!, $quantity: Int) {
142 | cartLinesAdd (cartId: $cartId, lines: [{ merchandiseId: $merchandiseId, quantity: $quantity }]) {
143 | cart {
144 | ...cartFragment
145 | }
146 | userErrors {
147 | field
148 | message
149 | }
150 | }
151 | }
152 | ${CART_FRAGMENT}
153 | `;
154 |
155 | export const RemoveCartLinesMutation = `#graphql
156 | mutation ($cartId: ID!, $lineIds: [ID!]!) {
157 | cartLinesRemove (cartId: $cartId, lineIds: $lineIds) {
158 | cart {
159 | ...cartFragment
160 | }
161 | userErrors {
162 | field
163 | message
164 | }
165 | }
166 | }
167 | ${CART_FRAGMENT}
168 | `;
169 |
--------------------------------------------------------------------------------
/src/utils/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const configSchema = z.object({
4 | shopifyShop: z.string(),
5 | publicShopifyAccessToken: z.string(),
6 | privateShopifyAccessToken: z.string(),
7 | apiVersion: z.string(),
8 | });
9 |
10 | export const MoneyV2Result = z.object({
11 | amount: z.string(),
12 | currencyCode: z.string(),
13 | });
14 |
15 | export const ImageResult = z
16 | .object({
17 | altText: z.string().nullable().optional(),
18 | url: z.string(),
19 | width: z.number().positive().int(),
20 | height: z.number().positive().int(),
21 | })
22 | .nullable();
23 |
24 | export const CartItemResult = z.object({
25 | id: z.string(),
26 | cost: z.object({
27 | amountPerQuantity: MoneyV2Result,
28 | subtotalAmount: MoneyV2Result,
29 | totalAmount: MoneyV2Result,
30 | }),
31 | merchandise: z.object({
32 | id: z.string(),
33 | title: z.string(),
34 | product: z.object({
35 | title: z.string(),
36 | handle: z.string(),
37 | }),
38 | image: ImageResult.nullable(),
39 | }),
40 | quantity: z.number().positive().int(),
41 | });
42 |
43 | export const CartResult = z
44 | .object({
45 | id: z.string(),
46 | cost: z.object({
47 | subtotalAmount: MoneyV2Result,
48 | }),
49 | checkoutUrl: z.string(),
50 | totalQuantity: z.number().int(),
51 | lines: z.object({
52 | nodes: z.array(CartItemResult),
53 | }),
54 | })
55 | .nullable();
56 |
57 | export const VariantResult = z.object({
58 | id: z.string(),
59 | title: z.string(),
60 | availableForSale: z.boolean(),
61 | quantityAvailable: z.number().int(),
62 | price: MoneyV2Result,
63 | });
64 |
65 | export const ProductResult = z
66 | .object({
67 | id: z.string(),
68 | title: z.string(),
69 | handle: z.string(),
70 | images: z.object({
71 | nodes: z.array(ImageResult),
72 | }),
73 | variants: z.object({
74 | nodes: z.array(VariantResult),
75 | }),
76 | featuredImage: ImageResult.nullable(),
77 | })
78 | .nullable();
79 |
--------------------------------------------------------------------------------
/src/utils/shopify.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { CartResult, ProductResult } from "./schemas";
3 | import { config } from "./config";
4 | import {
5 | ProductsQuery,
6 | ProductByHandleQuery,
7 | CreateCartMutation,
8 | AddCartLinesMutation,
9 | GetCartQuery,
10 | RemoveCartLinesMutation,
11 | ProductRecommendationsQuery,
12 | } from "./graphql";
13 |
14 | // Make a request to Shopify's GraphQL API and return the data object from the response body as JSON data.
15 | const makeShopifyRequest = async (
16 | query: string,
17 | variables: Record = {},
18 | buyerIP: string = ""
19 | ) => {
20 | const isSSR = import.meta.env.SSR;
21 | const apiUrl = `https://${config.shopifyShop}/api/${config.apiVersion}/graphql.json`;
22 |
23 | function getOptions() {
24 | // If the request is made from the server, we need to pass the private access token and the buyer IP
25 | isSSR &&
26 | !buyerIP &&
27 | console.error(
28 | `🔴 No buyer IP provided => make sure to pass the buyer IP when making a server side Shopify request.`
29 | );
30 |
31 | const { privateShopifyAccessToken, publicShopifyAccessToken } = config;
32 | const options = {
33 | method: "POST",
34 | headers: {},
35 | body: JSON.stringify({ query, variables }),
36 | };
37 | // Check if the Shopify request is made from the server or the client
38 | if (isSSR) {
39 | options.headers = {
40 | "Content-Type": "application/json",
41 | "Shopify-Storefront-Private-Token": privateShopifyAccessToken,
42 | "Shopify-Storefront-Buyer-IP": buyerIP,
43 | };
44 | return options;
45 | }
46 | options.headers = {
47 | "Content-Type": "application/json",
48 | "X-Shopify-Storefront-Access-Token": publicShopifyAccessToken,
49 | };
50 |
51 | return options;
52 | }
53 |
54 | const response = await fetch(apiUrl, getOptions());
55 |
56 | if (!response.ok) {
57 | const body = await response.text();
58 | throw new Error(`${response.status} ${body}`);
59 | }
60 |
61 | const json = await response.json();
62 | if (json.errors) {
63 | throw new Error(json.errors.map((e: Error) => e.message).join("\n"));
64 | }
65 |
66 | return json.data;
67 | };
68 |
69 | // Get all products or a limited number of products (default: 10)
70 | export const getProducts = async (options: {
71 | limit?: number;
72 | buyerIP: string;
73 | }) => {
74 | const { limit = 10, buyerIP } = options;
75 |
76 | const data = await makeShopifyRequest(
77 | ProductsQuery,
78 | { first: limit },
79 | buyerIP
80 | );
81 | const { products } = data;
82 |
83 | if (!products) {
84 | throw new Error("No products found");
85 | }
86 |
87 | const productsList = products.edges.map((edge: any) => edge.node);
88 | const ProductsResult = z.array(ProductResult);
89 | const parsedProducts = ProductsResult.parse(productsList);
90 |
91 | return parsedProducts;
92 | };
93 |
94 | // Get a product by its handle (slug)
95 | export const getProductByHandle = async (options: {
96 | handle: string;
97 | buyerIP: string;
98 | }) => {
99 | const { handle, buyerIP } = options;
100 |
101 | const data = await makeShopifyRequest(
102 | ProductByHandleQuery,
103 | { handle },
104 | buyerIP
105 | );
106 | const { product } = data;
107 |
108 | const parsedProduct = ProductResult.parse(product);
109 |
110 | return parsedProduct;
111 | };
112 |
113 | export const getProductRecommendations = async (options: {
114 | productId: string;
115 | buyerIP: string;
116 | }) => {
117 | const { productId, buyerIP } = options;
118 | const data = await makeShopifyRequest(
119 | ProductRecommendationsQuery,
120 | {
121 | productId,
122 | },
123 | buyerIP
124 | );
125 | const { productRecommendations } = data;
126 |
127 | const ProductsResult = z.array(ProductResult);
128 | const parsedProducts = ProductsResult.parse(productRecommendations);
129 |
130 | return parsedProducts;
131 | };
132 |
133 | // Create a cart and add a line item to it and return the cart object
134 | export const createCart = async (id: string, quantity: number) => {
135 | const data = await makeShopifyRequest(CreateCartMutation, { id, quantity });
136 | const { cartCreate } = data;
137 | const { cart } = cartCreate;
138 | const parsedCart = CartResult.parse(cart);
139 |
140 | return parsedCart;
141 | };
142 |
143 | // Add a line item to an existing cart (by ID) and return the updated cart object
144 | export const addCartLines = async (
145 | id: string,
146 | merchandiseId: string,
147 | quantity: number
148 | ) => {
149 | const data = await makeShopifyRequest(AddCartLinesMutation, {
150 | cartId: id,
151 | merchandiseId,
152 | quantity,
153 | });
154 | const { cartLinesAdd } = data;
155 | const { cart } = cartLinesAdd;
156 |
157 | const parsedCart = CartResult.parse(cart);
158 |
159 | return parsedCart;
160 | };
161 |
162 | // Remove line items from an existing cart (by IDs) and return the updated cart object
163 | export const removeCartLines = async (id: string, lineIds: string[]) => {
164 | const data = await makeShopifyRequest(RemoveCartLinesMutation, {
165 | cartId: id,
166 | lineIds,
167 | });
168 | const { cartLinesRemove } = data;
169 | const { cart } = cartLinesRemove;
170 | const parsedCart = CartResult.parse(cart);
171 |
172 | return parsedCart;
173 | };
174 |
175 | // Get a cart by its ID and return the cart object
176 | export const getCart = async (id: string) => {
177 | const data = await makeShopifyRequest(GetCartQuery, { id });
178 |
179 | const { cart } = data;
180 | const parsedCart = CartResult.parse(cart);
181 |
182 | return parsedCart;
183 | };
184 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strict"
3 | }
4 |
--------------------------------------------------------------------------------