Initial Source code

This commit is contained in:
jackon.tran 2026-10-27 15:09:07 +07:00
commit a03a361f35
256 changed files with 43606 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
node_modules
.next
.git
.env*
!.env.template
coverage
mock-backend
docs
tsconfig.tsbuildinfo

35
.env.template Normal file
View File

@ -0,0 +1,35 @@
# Your Medusa backend, should be updated to where you are hosting your server. Remember to update CORS settings for your server. See – https://docs.medusajs.com/learn/configurations/medusa-config#httpstorecors
MEDUSA_BACKEND_URL=http://localhost:9000
# Your publishable key that can be attached to sales channels. See - https://docs.medusajs.com/resources/storefront-development/publishable-api-keys
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_test
# Your store URL, should be updated to where you are hosting your storefront.
NEXT_PUBLIC_BASE_URL=http://localhost:8000
# Your preferred default region. When middleware cannot determine the user region from the "x-vercel-country" header, the default region will be used. ISO-2 lowercase format.
NEXT_PUBLIC_DEFAULT_REGION=vn
# Your Stripe public key. See – https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe
NEXT_PUBLIC_STRIPE_KEY=
# Your Medusa payments publishable key. You can find it in your Medusa dashbard.
NEXT_PUBLIC_MEDUSA_PAYMENTS_PUBLISHABLE_KEY=
# Your Medusa payments external account ID. You can find it in your Medusa dashbard.
NEXT_PUBLIC_MEDUSA_PAYMENTS_ACCOUNT_ID=
# Your Next.js revalidation secret. See – https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#on-demand-revalidation
REVALIDATE_SECRET=supersecret
# Extra hostnames allowed for next/image (comma-separated), e.g. the public
# ngrok/CDN host serving uploaded product images. The host of MEDUSA_BACKEND_URL
# is already allowed.
NEXT_PUBLIC_IMAGE_HOSTS=
# For Cloud users: add your Medusa Cloud S3 hostname and pathname to enable image optimization.
MEDUSA_CLOUD_S3_HOSTNAME=
MEDUSA_CLOUD_S3_PATHNAME=
# Newsletter sign-ups are stored by the Medusa backend (POST /store/newsletter);
# no extra configuration is needed here.

20
.eslintrc.js Normal file
View File

@ -0,0 +1,20 @@
const path = require("path")
module.exports = {
root: true,
extends: ["next/core-web-vitals"],
// `no-html-link-for-pages` resolves the pages/app directory from this
// setting; without it the rule throws on an undefined path and takes the
// whole lint run down with it.
settings: {
next: {
rootDir: __dirname,
},
},
ignorePatterns: [
"node_modules/",
".next/",
"mock-backend/",
"next-sitemap.js",
],
}

58
.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# IDEs
.idea
.vscode
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.*
!.env.template
# vercel
.vercel
# typescript
*.tsbuildinfo
node_modules
.yarn/*
!.yarn/releases
.swc
dump.rdb
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth
/docs
.env.dev

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"arrowParens": "always",
"semi": false,
"endOfLine": "auto",
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

942
.yarn/releases/yarn-4.12.0.cjs vendored Normal file

File diff suppressed because one or more lines are too long

7
.yarnrc.yml Normal file
View File

@ -0,0 +1,7 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.12.0.cjs

43
Dockerfile Normal file
View File

@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn/releases ./.yarn/releases
RUN yarn install --immutable
COPY . .
# NEXT_PUBLIC_* are inlined at build time; MEDUSA_BACKEND_URL is read by
# next.config.js (image hosts) and by pages fetched during build, so the
# backend must be reachable at this URL while building.
ARG MEDUSA_BACKEND_URL
ARG NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_DEFAULT_REGION=vn
ARG NEXT_PUBLIC_IMAGE_HOSTS
ARG NEXT_PUBLIC_STRIPE_KEY
ARG NEXT_PUBLIC_MEDUSA_PAYMENTS_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_MEDUSA_PAYMENTS_ACCOUNT_ID
ENV MEDUSA_BACKEND_URL=$MEDUSA_BACKEND_URL \
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=$NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY \
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL \
NEXT_PUBLIC_DEFAULT_REGION=$NEXT_PUBLIC_DEFAULT_REGION \
NEXT_PUBLIC_IMAGE_HOSTS=$NEXT_PUBLIC_IMAGE_HOSTS \
NEXT_PUBLIC_STRIPE_KEY=$NEXT_PUBLIC_STRIPE_KEY \
NEXT_PUBLIC_MEDUSA_PAYMENTS_PUBLISHABLE_KEY=$NEXT_PUBLIC_MEDUSA_PAYMENTS_PUBLISHABLE_KEY \
NEXT_PUBLIC_MEDUSA_PAYMENTS_ACCOUNT_ID=$NEXT_PUBLIC_MEDUSA_PAYMENTS_ACCOUNT_ID \
NEXT_TELEMETRY_DISABLED=1
RUN yarn build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
# Full node_modules on purpose: next.config.js -> check-env-variables.js
# requires ansi-colors (a devDependency) at startup.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json /app/next.config.js /app/check-env-variables.js ./
EXPOSE 8082
CMD ["node_modules/.bin/next", "start", "-p", "8082"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Medusa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

127
README.md Normal file
View File

@ -0,0 +1,127 @@
> ⚠️ This repository is now deprecated. Use the [dtc-starter](https://github.com/medusajs/dtc-starter) instead.
<p align="center">
<a href="https://www.medusajs.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
</picture>
</a>
</p>
<h1 align="center">
Medusa Next.js Starter Template
</h1>
<p align="center">
Combine Medusa's modules for your commerce backend with the newest Next.js 15 features for a performant storefront.</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
### Prerequisites
To use the [Next.js Starter Template](https://medusajs.com/nextjs-commerce/), you should have a Medusa server running locally on port 9000.
For a quick setup, run:
```shell
npx create-medusa-app@latest
```
Check out [create-medusa-app docs](https://docs.medusajs.com/learn/installation) for more details and troubleshooting.
# Overview
The Medusa Next.js Starter is built with:
- [Next.js](https://nextjs.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Typescript](https://www.typescriptlang.org/)
- [Medusa](https://medusajs.com/)
Features include:
- Full ecommerce support:
- Product Detail Page
- Product Overview Page
- Product Collections
- Cart
- Checkout with Stripe
- User Accounts
- Order Details
- Full Next.js 15 support:
- App Router
- Next fetching/caching
- Server Components
- Server Actions
- Streaming
- Static Pre-Rendering
# Quickstart
### Setting up the environment variables
Navigate into your projects directory and get your environment variables ready:
```shell
cd nextjs-starter-medusa/
mv .env.template .env.local
```
### Install dependencies
Use Yarn to install all dependencies.
```shell
yarn
```
### Start developing
You are now ready to start up your project.
```shell
yarn dev
```
### Open the code and start customizing
Your site is now running at http://localhost:8000!
# Payment integrations
By default this starter supports the following payment integrations
- [Stripe](https://stripe.com/)
To enable the integrations you need to add the following to your `.env.local` file:
```shell
NEXT_PUBLIC_STRIPE_KEY=<your-stripe-public-key>
```
You'll also need to setup the integrations in your Medusa server. See the [Medusa documentation](https://docs.medusajs.com) for more information on how to configure [Stripe](https://docs.medusajs.com/resources/commerce-modules/payment/payment-provider/stripe#main).
# Resources
## Learn more about Medusa
- [Website](https://www.medusajs.com/)
- [GitHub](https://github.com/medusajs)
- [Documentation](https://docs.medusajs.com/)
## Learn more about Next.js
- [Website](https://nextjs.org/)
- [GitHub](https://github.com/vercel/next.js)
- [Documentation](https://nextjs.org/docs)

102
RUNNING.md Normal file
View File

@ -0,0 +1,102 @@
# Running the storefront
The storefront serves on <http://localhost:8000> and always talks to a backend
on <http://localhost:9000>. Two backends can fill that slot — the real Medusa
app or the bundled mock. **Only one at a time**: they both bind port 9000.
---
## Option A — real Medusa backend (recommended)
```bash
cd backend && yarn dev
```
```bash
cd frontend && yarn dev
```
`frontend/.env.local` must match that backend on two values, or the storefront
will not load:
| Variable | Why it must match |
|---|---|
| `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY` | Every Store API call is rejected with `not_allowed` without a key the backend knows. `pk_test` is a placeholder that only works with the mock. |
| `NEXT_PUBLIC_DEFAULT_REGION` | Must be a country code the backend has a region for. The middleware redirects `/` to `/<countryCode>`; if the value has no region it falls back to whichever country comes first, which is rarely what you want. |
### Finding the publishable key
From the Medusa admin: **Settings → Publishable API keys**.
Or straight from the database:
```bash
cd backend && node -e "require('dotenv').config();const{Client}=require('pg');const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(async()=>{const r=await c.query(\"select token from api_key where type='publishable' and revoked_at is null\");console.log(r.rows.map(x=>x.token).join('\n'));await c.end()})"
```
### Finding the valid region country codes
```bash
cd backend && node -e "require('dotenv').config();const{Client}=require('pg');const c=new Client({connectionString:process.env.DATABASE_URL});c.connect().then(async()=>{const r=await c.query('select iso_2 from region_country where region_id is not null');console.log(r.rows.map(x=>x.iso_2).join(', '));await c.end()})"
```
The backend currently configured here has a single **Europe** region (EUR)
covering `dk, fr, de, it, es, se, gb` — there is no `us`, so `.env.local` is
set to `dk`.
Also check `STORE_CORS` in `backend/.env` includes `http://localhost:8000`.
### After editing `.env.local`
Restart `yarn dev`. Next.js reads env files at boot.
---
## Option B — mock backend (no database)
`frontend/mock-backend/` is a small Node server implementing enough of the
Store API — regions, categories, collections, products, search and an
in-memory cart — for browsing and add-to-bag to work with no database.
```bash
cd frontend && yarn mock-backend
```
```bash
cd frontend && yarn dev
```
With the mock, `.env.local` wants `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_test`
(the mock does not validate it) and `NEXT_PUBLIC_DEFAULT_REGION=us`.
Not emulated: checkout completion, payments, customer accounts, orders. Those
endpoints answer "nothing configured" so the storefront degrades gracefully
rather than erroring.
---
## Checks
```bash
cd frontend && yarn check
```
Runs `typecheck`, `lint` and `test`. `next build` also runs the type-check and
lint — they are no longer disabled in `next.config.js`, so a type error fails
the build.
Do not run `yarn build` while `yarn dev` is running: they share `.next` and the
build pulls the directory out from under the dev server.
---
## Branding
Everything user-visible that names the store lives in
[`src/lib/brand.ts`](src/lib/brand.ts) — wordmark, storage-key slug, tagline,
title helper and social links. Change `BRAND_NAME` and `BRAND_SLUG` there and
the whole storefront follows.
Footer copy for the informational pages lives in
[`src/lib/content/info-pages.ts`](src/lib/content/info-pages.ts); it is generic
boilerplate and should be replaced (or swapped for a CMS query) before launch.

39
check-env-variables.js Normal file
View File

@ -0,0 +1,39 @@
const c = require("ansi-colors")
const requiredEnvs = [
{
key: "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
// TODO: we need a good doc to point this to
description:
"Learn how to create a publishable key: https://docs.medusajs.com/v2/resources/storefront-development/publishable-api-keys",
},
]
function checkEnvVariables() {
const missingEnvs = requiredEnvs.filter(function (env) {
return !process.env[env.key]
})
if (missingEnvs.length > 0) {
console.error(
c.red.bold("\n🚫 Error: Missing required environment variables\n")
)
missingEnvs.forEach(function (env) {
console.error(c.yellow(` ${c.bold(env.key)}`))
if (env.description) {
console.error(c.dim(` ${env.description}\n`))
}
})
console.error(
c.yellow(
"\nPlease set these variables in your .env file or environment before starting the application.\n"
)
)
process.exit(1)
}
}
module.exports = checkEnvVariables

333
mock-backend/fixtures.js Normal file
View File

@ -0,0 +1,333 @@
/**
* Fixture data for the mock Medusa Store API.
*
* Shapes mirror what `@medusajs/js-sdk` expects from a real Medusa v2 store
* so the storefront can be developed and demoed without a running backend.
*/
const REGION = {
id: "reg_01",
name: "United States",
currency_code: "usd",
countries: [
{
iso_2: "us",
iso_3: "usa",
display_name: "United States",
name: "united states",
num_code: 840,
region_id: "reg_01",
},
],
}
const APPAREL_IMG = "/apparel_collection.png"
const BAGS_IMG = "/bags_collection.png"
// --- Categories -------------------------------------------------------------
// Declared flat, then linked into a parent/child tree below.
const CATEGORY_DEFS = [
{
id: "cat_ready_to_wear",
name: "Ready-to-Wear",
handle: "ready-to-wear",
description: "Tailoring, knitwear and everyday pieces.",
parent_id: null,
},
{
id: "cat_dresses",
name: "Dresses",
handle: "dresses",
description: "Day and evening dresses.",
parent_id: "cat_ready_to_wear",
},
{
id: "cat_knitwear",
name: "Knitwear",
handle: "knitwear",
description: "Cashmere, wool and cotton knits.",
parent_id: "cat_ready_to_wear",
},
{
id: "cat_outerwear",
name: "Outerwear",
handle: "outerwear",
description: "Coats, jackets and tailoring.",
parent_id: "cat_ready_to_wear",
},
{
id: "cat_bags",
name: "Bags",
handle: "bags",
description: "Bags and leather goods.",
parent_id: null,
},
{
id: "cat_totes",
name: "Totes",
handle: "totes",
description: "Structured and soft totes.",
parent_id: "cat_bags",
},
{
id: "cat_shoulder_bags",
name: "Shoulder Bags",
handle: "shoulder-bags",
description: "Shoulder and crossbody bags.",
parent_id: "cat_bags",
},
{
id: "cat_mini_bags",
name: "Mini Bags",
handle: "mini-bags",
description: "Compact evening and day bags.",
parent_id: "cat_bags",
},
{
id: "cat_accessories",
name: "Accessories",
handle: "accessories",
description: "Small leather goods and finishing pieces.",
parent_id: null,
},
]
const CATEGORIES = CATEGORY_DEFS.map((def) => ({
id: def.id,
name: def.name,
handle: def.handle,
description: def.description,
category_children: [],
parent_category: null,
}))
const CATEGORY_BY_ID = new Map(CATEGORIES.map((c) => [c.id, c]))
CATEGORY_DEFS.forEach((def) => {
if (!def.parent_id) {
return
}
const child = CATEGORY_BY_ID.get(def.id)
const parent = CATEGORY_BY_ID.get(def.parent_id)
// Parent references are trimmed to avoid a circular structure that
// JSON.stringify cannot serialise.
child.parent_category = {
id: parent.id,
name: parent.name,
handle: parent.handle,
parent_category: null,
}
parent.category_children.push({
id: child.id,
name: child.name,
handle: child.handle,
})
})
// --- Collections ------------------------------------------------------------
const COLLECTIONS = [
{ id: "col_summer_edit", title: "The Summer Edit", handle: "summer-edit" },
{ id: "col_signatures", title: "Signatures", handle: "signatures" },
{ id: "col_leather_goods", title: "Leather Goods", handle: "leather-goods" },
]
// --- Products ---------------------------------------------------------------
const SIZES = ["S", "M", "L"]
const PRODUCT_OPTIONS = (colors) => [
{
id: "opt_size",
title: "Size",
values: SIZES.map((value) => ({ value })),
},
{
id: "opt_color",
title: "Color",
values: colors.map((value) => ({ value })),
},
]
function makeVariants(id, basePrice, colors) {
const variants = []
SIZES.forEach((size) => {
colors.forEach((color) => {
variants.push({
id: `variant_${id}_${size}_${color}`.toLowerCase().replace(/\s+/g, "-"),
title: `${size} / ${color}`,
sku: `${id.toUpperCase()}-${size}-${color.slice(0, 3).toUpperCase()}`,
manage_inventory: true,
allow_backorder: false,
inventory_quantity: 10,
options: [
{ option_id: "opt_size", value: size },
{ option_id: "opt_color", value: color },
],
calculated_price: {
calculated_amount: basePrice,
original_amount: basePrice,
currency_code: REGION.currency_code,
calculated_price: { price_list_type: "default" },
},
images: [],
})
})
})
return variants
}
function makeProduct({
id,
title,
handle,
description,
thumbnail,
price,
categoryId,
collectionId,
material,
colors,
}) {
const category = CATEGORY_BY_ID.get(categoryId)
return {
id,
title,
handle,
description,
thumbnail,
material,
status: "published",
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
images: [
{ id: `img_${id}_1`, url: thumbnail },
{ id: `img_${id}_2`, url: thumbnail },
],
options: PRODUCT_OPTIONS(colors),
variants: makeVariants(id, price, colors),
categories: category
? [{ id: category.id, name: category.name, handle: category.handle }]
: [],
collection_id: collectionId,
collection: COLLECTIONS.find((c) => c.id === collectionId) ?? null,
tags: [],
metadata: {},
}
}
const PRODUCTS = [
makeProduct({
id: "prod_1",
title: "Wool Tailored Blazer",
handle: "wool-tailored-blazer",
description: "Structured wool blazer with a tailored silhouette.",
thumbnail: APPAREL_IMG,
price: 1890,
categoryId: "cat_outerwear",
collectionId: "col_signatures",
material: "Wool",
colors: ["Black", "Camel"],
}),
makeProduct({
id: "prod_2",
title: "Silk Midi Dress",
handle: "silk-midi-dress",
description: "Fluid silk dress with a modern midi length.",
thumbnail: APPAREL_IMG,
price: 1290,
categoryId: "cat_dresses",
collectionId: "col_summer_edit",
material: "Silk",
colors: ["Ivory", "Black"],
}),
makeProduct({
id: "prod_3",
title: "Cashmere Knit Sweater",
handle: "cashmere-knit-sweater",
description: "Soft cashmere knit in a relaxed fit.",
thumbnail: APPAREL_IMG,
price: 980,
categoryId: "cat_knitwear",
collectionId: "col_signatures",
material: "Cashmere",
colors: ["Grey", "Camel"],
}),
makeProduct({
id: "prod_4",
title: "Quilted Leather Shoulder Bag",
handle: "quilted-leather-shoulder-bag",
description: "Quilted leather shoulder bag with a chain strap.",
thumbnail: BAGS_IMG,
price: 2590,
categoryId: "cat_shoulder_bags",
collectionId: "col_leather_goods",
material: "Leather",
colors: ["Black", "Cognac"],
}),
makeProduct({
id: "prod_5",
title: "Leather Tote Bag",
handle: "leather-tote-bag",
description: "Spacious leather tote for everyday carry.",
thumbnail: BAGS_IMG,
price: 2190,
categoryId: "cat_totes",
collectionId: "col_leather_goods",
material: "Leather",
colors: ["Cognac", "Ivory"],
}),
makeProduct({
id: "prod_6",
title: "Mini Crossbody Bag",
handle: "mini-crossbody-bag",
description: "Compact crossbody bag in smooth leather.",
thumbnail: BAGS_IMG,
price: 1490,
categoryId: "cat_mini_bags",
collectionId: "col_leather_goods",
material: "Leather",
colors: ["Black", "Ivory"],
}),
makeProduct({
id: "prod_7",
title: "Raffia Summer Tote",
handle: "raffia-summer-tote",
description: "Hand-woven raffia tote with leather handles.",
thumbnail: BAGS_IMG,
price: 890,
categoryId: "cat_totes",
collectionId: "col_summer_edit",
material: "Raffia",
colors: ["Natural"],
}),
makeProduct({
id: "prod_8",
title: "Leather Card Holder",
handle: "leather-card-holder",
description: "Slim card holder in grained leather.",
thumbnail: BAGS_IMG,
price: 290,
categoryId: "cat_accessories",
collectionId: "col_leather_goods",
material: "Leather",
colors: ["Black", "Cognac"],
}),
makeProduct({
id: "prod_9",
title: "Linen Shirt Dress",
handle: "linen-shirt-dress",
description: "Relaxed linen shirt dress for warm days.",
thumbnail: APPAREL_IMG,
price: 740,
categoryId: "cat_dresses",
collectionId: "col_summer_edit",
material: "Linen",
colors: ["Ivory", "Sky"],
}),
]
module.exports = { REGION, CATEGORIES, COLLECTIONS, PRODUCTS }

394
mock-backend/server.js Normal file
View File

@ -0,0 +1,394 @@
/**
* Mock Medusa Store API.
*
* Enough of the read endpoints — plus an in-memory cart — for the storefront
* to run end to end without a Medusa instance. Checkout completion and
* payments are deliberately not emulated; run the real backend for those.
*
* node mock-backend/server.js (or: yarn mock-backend)
*/
const http = require("node:http")
const { randomUUID } = require("node:crypto")
const { parse } = require("node:url")
const qs = require("qs")
const { REGION, CATEGORIES, COLLECTIONS, PRODUCTS } = require("./fixtures")
const PORT = Number(process.env.MOCK_PORT || 9000)
// --- helpers ----------------------------------------------------------------
function sendJson(res, status, data) {
res.writeHead(status, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
})
res.end(JSON.stringify(data))
}
function toArray(value) {
if (value === undefined || value === null) return undefined
return Array.isArray(value) ? value : [value]
}
function readBody(req) {
return new Promise((resolve) => {
let raw = ""
req.on("data", (chunk) => {
raw += chunk
})
req.on("end", () => {
if (!raw) return resolve({})
try {
resolve(JSON.parse(raw))
} catch {
resolve({})
}
})
})
}
function cheapestAmount(product) {
const amounts = (product.variants ?? [])
.map((v) => v.calculated_price?.calculated_amount)
.filter((a) => typeof a === "number")
return amounts.length ? Math.min(...amounts) : 0
}
function sortProducts(products, order) {
if (!order) return products
const desc = order.startsWith("-")
const field = desc ? order.slice(1) : order
const sorted = [...products]
if (field === "price" || field === "variants.calculated_price") {
sorted.sort((a, b) => cheapestAmount(a) - cheapestAmount(b))
} else if (field === "title") {
sorted.sort((a, b) => a.title.localeCompare(b.title))
} else if (field === "created_at") {
sorted.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
} else {
return products
}
return desc ? sorted.reverse() : sorted
}
function paginate(items, query) {
const limit = Number(query.limit) || 100
const offset = Number(query.offset) || 0
return { items: items.slice(offset, offset + limit), count: items.length }
}
/** Descendant ids so filtering by a parent category also returns its children. */
function categoryIdsWithDescendants(ids) {
const result = new Set(ids)
let added = true
while (added) {
added = false
CATEGORIES.forEach((cat) => {
const parentId = cat.parent_category?.id
if (parentId && result.has(parentId) && !result.has(cat.id)) {
result.add(cat.id)
added = true
}
})
}
return result
}
// --- in-memory carts --------------------------------------------------------
const carts = new Map()
function findVariant(variantId) {
for (const product of PRODUCTS) {
const variant = product.variants.find((v) => v.id === variantId)
if (variant) return { product, variant }
}
return null
}
function recalculate(cart) {
cart.items.forEach((item) => {
item.subtotal = item.unit_price * item.quantity
item.total = item.subtotal
item.original_total = item.subtotal
})
const subtotal = cart.items.reduce((acc, item) => acc + item.total, 0)
cart.subtotal = subtotal
cart.discount_total = 0
cart.shipping_total = 0
cart.tax_total = 0
cart.item_total = subtotal
cart.original_total = subtotal
cart.total = subtotal
return cart
}
function createCart() {
const cart = {
id: `cart_${randomUUID()}`,
region_id: REGION.id,
region: REGION,
currency_code: REGION.currency_code,
email: null,
customer_id: null,
shipping_address: null,
billing_address: null,
shipping_methods: [],
payment_collection: null,
promotions: [],
items: [],
metadata: {},
}
carts.set(cart.id, recalculate(cart))
return cart
}
function addLineItem(cart, { variant_id, quantity = 1, metadata }) {
const found = findVariant(variant_id)
if (!found) return null
const { product, variant } = found
const existing = cart.items.find((i) => i.variant_id === variant_id)
if (existing) {
existing.quantity += quantity
} else {
cart.items.push({
id: `item_${randomUUID()}`,
cart_id: cart.id,
title: product.title,
product_id: product.id,
product_title: product.title,
product_handle: product.handle,
thumbnail: product.thumbnail,
variant_id: variant.id,
variant: { ...variant, product },
quantity,
unit_price: variant.calculated_price.calculated_amount,
created_at: new Date().toISOString(),
metadata: metadata ?? {},
})
}
return recalculate(cart)
}
// --- routing ----------------------------------------------------------------
const server = http.createServer(async (req, res) => {
if (req.method === "OPTIONS") {
return sendJson(res, 200, {})
}
const { pathname, query: rawQuery } = parse(req.url)
const query = qs.parse(rawQuery)
const body = req.method === "GET" ? {} : await readBody(req)
console.log(`[mock-backend] ${req.method} ${req.url}`)
// Regions
if (pathname === "/store/regions") {
return sendJson(res, 200, { regions: [REGION], count: 1 })
}
if (pathname.startsWith("/store/regions/")) {
const id = pathname.split("/").pop()
if (id !== REGION.id) {
return sendJson(res, 404, { message: "Region not found" })
}
return sendJson(res, 200, { region: REGION })
}
// Collections
if (pathname === "/store/collections") {
let collections = COLLECTIONS
if (query.handle) {
collections = collections.filter((c) => c.handle === query.handle)
}
const withProducts = collections.map((c) => ({
...c,
products: PRODUCTS.filter((p) => p.collection_id === c.id),
}))
return sendJson(res, 200, {
collections: withProducts,
count: withProducts.length,
})
}
if (pathname.startsWith("/store/collections/")) {
const id = pathname.split("/").pop()
const collection = COLLECTIONS.find((c) => c.id === id)
if (!collection) {
return sendJson(res, 404, { message: "Collection not found" })
}
return sendJson(res, 200, { collection })
}
// Categories
if (pathname === "/store/product-categories") {
let categories = CATEGORIES
if (query.handle) {
categories = categories.filter((c) => c.handle === query.handle)
}
const withProducts = categories.map((c) => ({
...c,
products: PRODUCTS.filter((p) =>
p.categories.some((pc) => pc.id === c.id)
),
}))
return sendJson(res, 200, {
product_categories: withProducts,
count: withProducts.length,
})
}
// Products
if (pathname === "/store/products") {
let products = PRODUCTS
if (query.handle) {
products = products.filter((p) => p.handle === query.handle)
}
if (query.q) {
const term = String(query.q).toLowerCase()
products = products.filter(
(p) =>
p.title.toLowerCase().includes(term) ||
(p.description ?? "").toLowerCase().includes(term) ||
(p.material ?? "").toLowerCase().includes(term)
)
}
const collectionIds = toArray(query.collection_id)
if (collectionIds) {
products = products.filter((p) => collectionIds.includes(p.collection_id))
}
const categoryIds = toArray(query.category_id)
if (categoryIds) {
const allowed = categoryIdsWithDescendants(categoryIds)
products = products.filter((p) =>
p.categories.some((c) => allowed.has(c.id))
)
}
const ids = toArray(query.id)
if (ids) {
products = products.filter((p) => ids.includes(p.id))
}
products = sortProducts(products, query.order)
const { items, count } = paginate(products, query)
return sendJson(res, 200, { products: items, count })
}
if (pathname.startsWith("/store/product-variants/")) {
const id = pathname.split("/").pop()
const found = findVariant(id)
if (!found) {
return sendJson(res, 404, { message: "Variant not found" })
}
return sendJson(res, 200, { variant: found.variant })
}
// Carts
if (pathname === "/store/carts" && req.method === "POST") {
return sendJson(res, 200, { cart: createCart() })
}
if (pathname.startsWith("/store/carts/")) {
const segments = pathname.split("/").filter(Boolean) // store, carts, :id, ...
const cartId = segments[2]
const cart = carts.get(cartId)
if (!cart) {
return sendJson(res, 404, { message: "Cart not found" })
}
// /store/carts/:id
if (segments.length === 3) {
if (req.method === "POST") {
Object.assign(cart, body)
return sendJson(res, 200, { cart: recalculate(cart) })
}
return sendJson(res, 200, { cart: recalculate(cart) })
}
// /store/carts/:id/line-items[/:lineId]
if (segments[3] === "line-items") {
const lineId = segments[4]
if (!lineId && req.method === "POST") {
const updated = addLineItem(cart, body)
if (!updated) {
return sendJson(res, 400, { message: "Variant not found" })
}
return sendJson(res, 200, { cart: updated })
}
const item = cart.items.find((i) => i.id === lineId)
if (!item) {
return sendJson(res, 404, { message: "Line item not found" })
}
if (req.method === "DELETE") {
cart.items = cart.items.filter((i) => i.id !== lineId)
return sendJson(res, 200, {
id: lineId,
object: "line-item",
deleted: true,
parent: recalculate(cart),
})
}
if (req.method === "POST") {
if (typeof body.quantity === "number") {
item.quantity = body.quantity
}
return sendJson(res, 200, { cart: recalculate(cart) })
}
}
return sendJson(res, 404, {
message: `Mock backend: no cart handler for ${req.method} ${pathname}`,
})
}
// Checkout-adjacent endpoints: answered as "nothing configured" so the
// storefront degrades gracefully instead of erroring.
if (pathname === "/store/shipping-options") {
return sendJson(res, 200, { shipping_options: [] })
}
if (pathname === "/store/payment-providers") {
return sendJson(res, 200, { payment_providers: [] })
}
if (pathname === "/store/customers/me") {
return sendJson(res, 401, { message: "Unauthorized" })
}
if (pathname === "/store/locales") {
return sendJson(res, 404, { message: "Not Found" })
}
return sendJson(res, 404, {
message: `Mock backend: no handler for ${pathname}`,
})
})
server.listen(PORT, () => {
console.log(`[mock-backend] listening on http://localhost:${PORT}`)
})

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

80
next.config.js Normal file
View File

@ -0,0 +1,80 @@
const checkEnvVariables = require("./check-env-variables")
checkEnvVariables()
/**
* Medusa Cloud-related environment variables
*/
const S3_HOSTNAME = process.env.MEDUSA_CLOUD_S3_HOSTNAME
const S3_PATHNAME = process.env.MEDUSA_CLOUD_S3_PATHNAME
/**
* Hosts that may serve product/category images through next/image: the
* Medusa backend itself (local file provider serves uploads from /static) plus
* any extra hosts listed in NEXT_PUBLIC_IMAGE_HOSTS (comma-separated, e.g. an
* ngrok or CDN hostname). Without this, uploaded images are blocked with
* "hostname is not configured under images".
*/
const extraImagePatterns = []
const imageHosts = [
process.env.MEDUSA_BACKEND_URL,
...(process.env.NEXT_PUBLIC_IMAGE_HOSTS || "").split(","),
]
for (const raw of imageHosts) {
const value = raw && raw.trim()
if (!value) continue
try {
const url = new URL(value.includes("://") ? value : `https://${value}`)
extraImagePatterns.push({
protocol: url.protocol.replace(":", ""),
hostname: url.hostname,
...(url.port ? { port: url.port } : {}),
})
} catch {
// ignore malformed entries
}
}
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
logging: {
fetches: {
fullUrl: true,
},
},
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "medusa-public-images.s3.eu-west-1.amazonaws.com",
},
{
protocol: "https",
hostname: "medusa-server-testing.s3.amazonaws.com",
},
{
protocol: "https",
hostname: "medusa-server-testing.s3.us-east-1.amazonaws.com",
},
...extraImagePatterns,
...(S3_HOSTNAME && S3_PATHNAME
? [
{
protocol: "https",
hostname: S3_HOSTNAME,
pathname: S3_PATHNAME,
},
]
: []),
],
},
}
module.exports = nextConfig

14575
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

76
package.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "hoo-storefront",
"version": "1.0.0",
"private": true,
"description": "HOO Store Front-end Storefront",
"keywords": [
"medusa-storefront"
],
"scripts": {
"dev": "next dev --turbopack -p 8000",
"build": "next build",
"start": "next start -p 8000",
"lint": "next lint",
"analyze": "ANALYZE=true next build",
"test": "vitest run",
"test:watch": "vitest",
"mock-backend": "node mock-backend/server.js",
"typecheck": "tsc --noEmit",
"check": "yarn typecheck && yarn lint && yarn test"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@medusajs/icons": "latest",
"@medusajs/js-sdk": "2.18.0",
"@medusajs/ui": "latest",
"@radix-ui/react-accordion": "^1.2.1",
"@stripe/react-stripe-js": "^5.3.0",
"@stripe/stripe-js": "^8.2.0",
"lodash": "^4.17.21",
"next": "15.3.9",
"pg": "^8.11.3",
"qs": "^6.12.1",
"react": "19.0.5",
"react-country-flag": "^3.1.0",
"react-dom": "19.0.5",
"server-only": "^0.0.1",
"tailwindcss-radix": "^2.8.0",
"webpack": "^5"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@medusajs/types": "2.18.0",
"@medusajs/ui-preset": "latest",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/lodash": "^4.14.195",
"@types/node": "17.0.21",
"@types/pg": "^8.11.0",
"@types/react": "19.0.5",
"@types/react-dom": "19.0.5",
"@types/react-instantsearch-dom": "^6.12.3",
"@vitejs/plugin-react": "^6.0.3",
"ansi-colors": "^4.1.3",
"autoprefixer": "^10.4.2",
"babel-loader": "^8.2.3",
"eslint": "^8.57.1",
"eslint-config-next": "15.0.8",
"jsdom": "^29.1.1",
"postcss": "^8.4.8",
"prettier": "^2.8.8",
"tailwindcss": "^3.0.23",
"typescript": "^5.3.2",
"vite": "^8.1.4",
"vitest": "^4.1.10"
},
"resolutions": {
"@types/react": "19.0.5",
"@types/react-dom": "19.0.5"
},
"overrides": {
"react": "19.0.5",
"react-dom": "19.0.5"
},
"packageManager": "yarn@4.12.0"
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

BIN
public/bags_collection.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,30 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import PaymentWrapper from "@modules/checkout/components/payment-wrapper"
import CheckoutForm from "@modules/checkout/templates/checkout-form"
import CheckoutSummary from "@modules/checkout/templates/checkout-summary"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Checkout",
}
export default async function Checkout() {
const cart = await retrieveCart()
if (!cart) {
return notFound()
}
const customer = await retrieveCustomer()
return (
<div className="grid grid-cols-1 small:grid-cols-[1fr_416px] content-container gap-x-40 py-12">
<PaymentWrapper cart={cart}>
<CheckoutForm cart={cart} customer={customer} />
</PaymentWrapper>
<CheckoutSummary cart={cart} />
</div>
)
}

View File

@ -0,0 +1,44 @@
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import Logo from "@modules/common/components/logo"
import ChevronDown from "@modules/common/icons/chevron-down"
import MedusaCTA from "@modules/layout/components/medusa-cta"
export default function CheckoutLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="w-full bg-white relative small:min-h-screen">
<div className="h-16 bg-white border-b ">
<nav className="flex h-full items-center content-container justify-between">
<LocalizedClientLink
href="/cart"
className="text-small-semi text-ui-fg-base flex items-center gap-x-2 uppercase flex-1 basis-0"
data-testid="back-to-cart-link"
>
<ChevronDown className="rotate-90" size={16} />
<span className="mt-px hidden small:block txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base ">
Back to shopping cart
</span>
<span className="mt-px block small:hidden txt-compact-plus text-ui-fg-subtle hover:text-ui-fg-base">
Back
</span>
</LocalizedClientLink>
<LocalizedClientLink
href="/"
className="flex items-center hover:opacity-90 transition-opacity"
data-testid="store-link"
>
<Logo size="sm" variant="full" />
</LocalizedClientLink>
<div className="flex-1 basis-0" />
</nav>
</div>
<div className="relative" data-testid="checkout-container">{children}</div>
<div className="py-4 w-full flex items-center justify-center">
<MedusaCTA />
</div>
</div>
)
}

View File

@ -0,0 +1,19 @@
import InteractiveLink from "@modules/common/components/interactive-link"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default async function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}

View File

@ -0,0 +1,38 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import AddressBook from "@modules/account/components/address-book"
import { getRegion } from "@lib/data/regions"
import { retrieveCustomer } from "@lib/data/customer"
export const metadata: Metadata = {
title: "Addresses",
description: "View your addresses",
}
export default async function Addresses(props: {
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const customer = await retrieveCustomer()
const region = await getRegion(countryCode)
if (!customer || !region) {
notFound()
}
return (
<div className="w-full" data-testid="addresses-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Shipping Addresses</h1>
<p className="text-base-regular">
View and update your shipping addresses, you can add as many as you
like. Saving your addresses will make them available during checkout.
</p>
</div>
<AddressBook customer={customer} region={region} />
</div>
)
}

View File

@ -0,0 +1,9 @@
import Spinner from "@modules/common/icons/spinner"
export default function Loading() {
return (
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
<Spinner size={36} />
</div>
)
}

View File

@ -0,0 +1,33 @@
import { retrieveOrder } from "@lib/data/orders"
import OrderDetailsTemplate from "@modules/order/templates/order-details-template"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: Promise<{ id: string }>
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
notFound()
}
return {
title: `Order #${order.display_id}`,
description: `View your order`,
}
}
export default async function OrderDetailPage(props: Props) {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
notFound()
}
return <OrderDetailsTemplate order={order} />
}

View File

@ -0,0 +1,37 @@
import { Metadata } from "next"
import OrderOverview from "@modules/account/components/order-overview"
import { notFound } from "next/navigation"
import { listOrders } from "@lib/data/orders"
import Divider from "@modules/common/components/divider"
import TransferRequestForm from "@modules/account/components/transfer-request-form"
export const metadata: Metadata = {
title: "Orders",
description: "Overview of your previous orders.",
}
export default async function Orders() {
const orders = await listOrders()
if (!orders) {
notFound()
}
return (
<div className="w-full" data-testid="orders-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Orders</h1>
<p className="text-base-regular">
View your previous orders and their status. You can also create
returns or exchanges for your orders if needed.
</p>
</div>
<div>
<OrderOverview orders={orders} />
<Divider className="my-16" />
<TransferRequestForm />
</div>
</div>
)
}

View File

@ -0,0 +1,22 @@
import { Metadata } from "next"
import Overview from "@modules/account/components/overview"
import { notFound } from "next/navigation"
import { retrieveCustomer } from "@lib/data/customer"
import { listOrders } from "@lib/data/orders"
export const metadata: Metadata = {
title: "Account",
description: "Overview of your account activity.",
}
export default async function OverviewTemplate() {
const customer = await retrieveCustomer().catch(() => null)
const orders = (await listOrders().catch(() => null)) || null
if (!customer) {
notFound()
}
return <Overview customer={customer} orders={orders} />
}

View File

@ -0,0 +1,55 @@
import { BRAND_NAME } from "@lib/brand"
import { Metadata } from "next"
import ProfilePhone from "@modules/account//components/profile-phone"
import ProfileBillingAddress from "@modules/account/components/profile-billing-address"
import ProfileEmail from "@modules/account/components/profile-email"
import ProfileName from "@modules/account/components/profile-name"
import ProfilePassword from "@modules/account/components/profile-password"
import { notFound } from "next/navigation"
import { listRegions } from "@lib/data/regions"
import { retrieveCustomer } from "@lib/data/customer"
export const metadata: Metadata = {
title: "Profile",
description: `View and edit your ${'${BRAND_NAME}'} profile.`,
}
export default async function Profile() {
const customer = await retrieveCustomer()
const regions = await listRegions()
if (!customer || !regions) {
notFound()
}
return (
<div className="w-full" data-testid="profile-page-wrapper">
<div className="mb-8 flex flex-col gap-y-4">
<h1 className="text-2xl-semi">Profile</h1>
<p className="text-base-regular">
View and update your profile information, including your name, email,
and phone number. You can also update your billing address, or change
your password.
</p>
</div>
<div className="flex flex-col gap-y-8 w-full">
<ProfileName customer={customer} />
<Divider />
<ProfileEmail customer={customer} />
<Divider />
<ProfilePhone customer={customer} />
<Divider />
{/* <ProfilePassword customer={customer} />
<Divider /> */}
<ProfileBillingAddress customer={customer} regions={regions} />
</div>
</div>
)
}
const Divider = () => {
return <div className="w-full h-px bg-gray-200" />
}
;``

View File

@ -0,0 +1,13 @@
import { BRAND_NAME } from "@lib/brand"
import { Metadata } from "next"
import LoginTemplate from "@modules/account/templates/login-template"
export const metadata: Metadata = {
title: "Sign in",
description: `Sign in to your ${'${BRAND_NAME}'} account.`,
}
export default function Login() {
return <LoginTemplate />
}

View File

@ -0,0 +1,20 @@
import { retrieveCustomer } from "@lib/data/customer"
import { Toaster } from "@medusajs/ui"
import AccountLayout from "@modules/account/templates/account-layout"
export default async function AccountPageLayout({
dashboard,
login,
}: {
dashboard?: React.ReactNode
login?: React.ReactNode
}) {
const customer = await retrieveCustomer().catch(() => null)
return (
<AccountLayout customer={customer}>
{customer ? dashboard : login}
<Toaster />
</AccountLayout>
)
}

View File

@ -0,0 +1,9 @@
import Spinner from "@modules/common/icons/spinner"
export default function Loading() {
return (
<div className="flex items-center justify-center w-full h-full text-ui-fg-base">
<Spinner size={36} />
</div>
)
}

View File

@ -0,0 +1,5 @@
import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page"
export default function Loading() {
return <SkeletonCartPage />
}

View File

@ -0,0 +1,21 @@
import { Metadata } from "next"
import InteractiveLink from "@modules/common/components/interactive-link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The cart you tried to access does not exist. Clear your cookies and try
again.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}

View File

@ -0,0 +1,21 @@
import { retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import CartTemplate from "@modules/cart/templates"
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Cart",
description: "View your cart",
}
export default async function Cart() {
const cart = await retrieveCart().catch((error) => {
console.error(error)
return notFound()
})
const customer = await retrieveCustomer()
return <CartTemplate cart={cart} customer={customer} />
}

View File

@ -0,0 +1,120 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { pageTitle } from "@lib/brand"
import { getCategoryByHandle, listCategories } from "@lib/data/categories"
import { listCollections } from "@lib/data/collections"
import { listRegions } from "@lib/data/regions"
import { StoreRegion } from "@medusajs/types"
import CategoryTemplate from "@modules/categories/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type Props = {
params: Promise<{ category: string[]; countryCode: string }>
searchParams: Promise<{
sortBy?: SortOptions
page?: string
collection_id?: string
}>
}
export async function generateStaticParams() {
try {
const product_categories = await listCategories()
if (!product_categories) {
return []
}
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
)
const categoryHandles = product_categories.map(
(category: any) => category.handle
)
const staticParams = countryCodes
?.map((countryCode: string | undefined) =>
categoryHandles.map((handle: any) => ({
countryCode,
category: [handle],
}))
)
.flat()
return staticParams
} catch (error) {
console.error(
`Failed to generate static paths for category pages: ${
error instanceof Error ? error.message : "Unknown error"
}.`
)
return []
}
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
try {
const productCategory = await getCategoryByHandle(params.category)
const description =
productCategory.description ?? `${productCategory.name} category.`
return {
title: pageTitle(productCategory.name),
description,
alternates: {
canonical: `${params.category.join("/")}`,
},
}
} catch (error) {
notFound()
}
}
export default async function CategoryPage(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const { sortBy, page, collection_id } = searchParams
const [productCategory, collectionsResult] = await Promise.all([
getCategoryByHandle(params.category),
listCollections({ fields: "id,handle,title" }).catch(() => ({
collections: [],
count: 0,
})),
])
if (!productCategory) {
notFound()
}
const collections = collectionsResult?.collections ?? []
const filterGroups = collections.length
? [
{
key: "collection_id",
label: "Collection",
options: collections.map((c) => ({ value: c.id, label: c.title })),
},
]
: []
const collectionIds = collection_id
? collection_id.split(",").filter(Boolean)
: undefined
return (
<CategoryTemplate
category={productCategory}
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
filterGroups={filterGroups}
collectionIds={collectionIds}
/>
)
}

View File

@ -0,0 +1,100 @@
import { Metadata } from "next"
import { pageTitle } from "@lib/brand"
import { notFound } from "next/navigation"
import { getCollectionByHandle, listCollections } from "@lib/data/collections"
import { listRegions } from "@lib/data/regions"
import { StoreCollection, StoreRegion } from "@medusajs/types"
import CollectionTemplate from "@modules/collections/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
type Props = {
params: Promise<{ handle: string; countryCode: string }>
searchParams: Promise<{
page?: string
sortBy?: SortOptions
}>
}
export const PRODUCT_LIMIT = 12
export async function generateStaticParams() {
try {
const { collections } = await listCollections({
fields: "*products",
})
if (!collections) {
return []
}
const countryCodes = await listRegions().then(
(regions: StoreRegion[]) =>
regions
?.map((r) => r.countries?.map((c) => c.iso_2))
.flat()
.filter(Boolean) as string[]
)
const collectionHandles = collections.map(
(collection: StoreCollection) => collection.handle
)
const staticParams = countryCodes
?.map((countryCode: string) =>
collectionHandles.map((handle: string | undefined) => ({
countryCode,
handle,
}))
)
.flat()
return staticParams
} catch (error) {
console.error(
`Failed to generate static paths for collection pages: ${
error instanceof Error ? error.message : "Unknown error"
}.`
)
return []
}
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const collection = await getCollectionByHandle(params.handle)
if (!collection) {
notFound()
}
const metadata = {
title: pageTitle(collection.title),
description: `${collection.title} collection`,
} as Metadata
return metadata
}
export default async function CollectionPage(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const { sortBy, page } = searchParams
const collection = await getCollectionByHandle(params.handle).then(
(collection: StoreCollection) => collection
)
if (!collection) {
notFound()
}
return (
<CollectionTemplate
collection={collection}
page={page}
sortBy={sortBy}
countryCode={params.countryCode}
/>
)
}

View File

@ -0,0 +1,111 @@
import { Metadata } from "next"
import Image from "next/image"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { listCollections } from "@lib/data/collections"
import { pageTitle } from "@lib/brand"
export const metadata: Metadata = {
title: pageTitle("Collections"),
description:
"Browse our seasonal editorial lookbooks and collection showcases.",
}
export default async function CollectionsPage() {
const { collections } = await listCollections({
fields: "id, handle, title, metadata",
})
// No invented placeholders: an empty catalogue should read as empty rather
// than linking to collections that do not exist.
const displayCollections = collections ?? []
return (
<div className="bg-brand-bg min-h-screen py-12 text-brand-primary">
<div className="content-container flex flex-col w-full">
{/* Title */}
<div className="text-center mb-12 flex flex-col gap-3">
<span className="text-[10px] tracking-[0.25em] font-sans font-semibold text-ui-fg-muted uppercase">
The archives
</span>
<h1 className="font-serif text-3xl sm:text-4xl uppercase tracking-wider">
Collections
</h1>
<div className="w-12 h-[1px] bg-brand-primary mx-auto mt-2" />
</div>
{displayCollections.length === 0 && (
<p className="py-20 text-center text-xs font-sans uppercase tracking-widest text-ui-fg-subtle">
No collections have been published yet.
</p>
)}
{/* Primary Featured Collection (Large Full Width) */}
{displayCollections[0] && (
<LocalizedClientLink
href={`/collections/${displayCollections[0].handle}`}
className="group flex flex-col items-center gap-6 mb-16"
>
<div className="relative w-full aspect-[16/9] max-h-[600px] overflow-hidden bg-brand-cream border border-brand-stone/30 rounded-none">
<Image
src="/apparel_collection.png"
alt={displayCollections[0].title}
fill
className="object-cover object-center transition-transform duration-700 group-hover:scale-[1.02]"
priority
sizes="100vw"
/>
</div>
<div className="flex flex-col items-center text-center gap-1.5">
<h2 className="font-serif text-2xl uppercase tracking-wider">
{displayCollections[0].title}
</h2>
<span className="text-[10px] tracking-widest uppercase font-sans font-semibold border-b border-brand-primary pb-1 hover:text-ui-fg-muted transition-colors">
DISCOVER
</span>
</div>
</LocalizedClientLink>
)}
{/* Secondary Collections Grid (2-column layout) */}
{displayCollections.length > 1 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
{displayCollections.slice(1).map((col, idx) => (
<LocalizedClientLink
key={col.id}
href={`/collections/${col.handle}`}
className="group flex flex-col items-center gap-6"
>
<div className="relative w-full aspect-[4/3] overflow-hidden bg-brand-cream border border-brand-stone/30 rounded-none">
<Image
src={idx % 2 === 0 ? "/bags_collection.png" : "/apparel_collection.png"}
alt={col.title}
fill
className="object-cover object-center transition-transform duration-700 group-hover:scale-[1.03]"
sizes="50vw"
/>
</div>
<div className="flex flex-col items-center text-center gap-1.5">
<h3 className="font-serif text-xl uppercase tracking-wider">
{col.title}
</h3>
<span className="text-[10px] tracking-widest uppercase font-sans font-semibold border-b border-brand-primary pb-1 hover:text-ui-fg-muted transition-colors">
DISCOVER
</span>
</div>
</LocalizedClientLink>
))}
</div>
)}
{/* Breadcrumbs */}
<div className="mt-8 border-t border-brand-stone/30 pt-6 text-[10px] font-sans uppercase tracking-wider text-ui-fg-muted flex items-center gap-x-2">
<LocalizedClientLink href="/" className="hover:text-black">
HOME
</LocalizedClientLink>
<span>/</span>
<span className="text-brand-primary font-medium">COLLECTIONS</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { pageTitle } from "@lib/brand"
import { INFO_PAGES, getInfoPage } from "@lib/content/info-pages"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
type Props = {
params: Promise<{ countryCode: string; handle: string }>
}
export async function generateStaticParams() {
return INFO_PAGES.map((page) => ({ handle: page.handle }))
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const { handle } = await props.params
const page = getInfoPage(handle)
if (!page) {
notFound()
}
return {
title: pageTitle(page.title),
description: page.intro,
}
}
export default async function InfoPage(props: Props) {
const { handle } = await props.params
const page = getInfoPage(handle)
if (!page) {
notFound()
}
return (
<div className="bg-brand-bg min-h-screen py-12 text-brand-primary">
<div className="content-container flex flex-col w-full max-w-3xl">
<h1 className="font-serif text-3xl sm:text-4xl uppercase tracking-wider">
{page.title}
</h1>
<p className="mt-4 text-sm text-ui-fg-subtle leading-relaxed font-sans">
{page.intro}
</p>
<div className="mt-12 flex flex-col gap-10">
{page.sections.map((section) => (
<section key={section.heading} className="flex flex-col gap-3">
<h2 className="text-[11px] font-semibold tracking-[0.15em] uppercase font-sans">
{section.heading}
</h2>
{section.body.map((paragraph) => (
<p
key={paragraph}
className="text-xs text-ui-fg-subtle leading-relaxed font-sans"
>
{paragraph}
</p>
))}
</section>
))}
</div>
<div className="mt-20 border-t border-brand-stone/30 pt-6 text-[10px] font-sans uppercase tracking-wider text-ui-fg-muted flex items-center gap-x-2">
<LocalizedClientLink href="/" className="hover:text-black">
Home
</LocalizedClientLink>
<span>/</span>
<span className="text-brand-primary font-medium">{page.title}</span>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,45 @@
import { Metadata } from "next"
import { listCartOptions, retrieveCart } from "@lib/data/cart"
import { retrieveCustomer } from "@lib/data/customer"
import { getBaseURL } from "@lib/util/env"
import { StoreCartShippingOption } from "@medusajs/types"
import CartMismatchBanner from "@modules/layout/components/cart-mismatch-banner"
import Footer from "@modules/layout/templates/footer"
import Nav from "@modules/layout/templates/nav"
import FreeShippingPriceNudge from "@modules/shipping/components/free-shipping-price-nudge"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
export default async function PageLayout(props: { children: React.ReactNode }) {
const customer = await retrieveCustomer()
const cart = await retrieveCart()
let shippingOptions: StoreCartShippingOption[] = []
if (cart) {
const { shipping_options } = await listCartOptions()
shippingOptions = shipping_options
}
return (
<>
<Nav />
{customer && cart && (
<CartMismatchBanner customer={customer} cart={cart} />
)}
{cart && (
<FreeShippingPriceNudge
variant="popup"
cart={cart}
shippingOptions={shippingOptions}
/>
)}
{props.children}
<Footer />
</>
)
}

View File

@ -0,0 +1,20 @@
import { Metadata } from "next"
import InteractiveLink from "@modules/common/components/interactive-link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<InteractiveLink href="/">Go to frontpage</InteractiveLink>
</div>
)
}

View File

@ -0,0 +1,5 @@
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
export default function Loading() {
return <SkeletonOrderConfirmed />
}

View File

@ -0,0 +1,23 @@
import { retrieveOrder } from "@lib/data/orders"
import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
import { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = {
params: Promise<{ id: string }>
}
export const metadata: Metadata = {
title: "Order Confirmed",
description: "You purchase was successful",
}
export default async function OrderConfirmedPage(props: Props) {
const params = await props.params
const order = await retrieveOrder(params.id).catch(() => null)
if (!order) {
return notFound()
}
return <OrderCompletedTemplate order={order} />
}

View File

@ -0,0 +1,41 @@
import { acceptTransferRequest } from "@lib/data/orders"
import { Heading, Text } from "@medusajs/ui"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
const { success, error } = await acceptTransferRequest(id, token)
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfered!
</Heading>
<Text className="text-zinc-600">
Order {id} has been successfully transfered to the new owner.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error accepting the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,41 @@
import { declineTransferRequest } from "@lib/data/orders"
import { Heading, Text } from "@medusajs/ui"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
const { success, error } = await declineTransferRequest(id, token)
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
{success && (
<>
<Heading level="h1" className="text-xl text-zinc-900">
Order transfer declined!
</Heading>
<Text className="text-zinc-600">
Transfer of order {id} has been successfully declined.
</Text>
</>
)}
{!success && (
<>
<Text className="text-zinc-600">
There was an error declining the transfer. Please try again.
</Text>
{error && (
<Text className="text-red-500">Error message: {error}</Text>
)}
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,38 @@
import { Heading, Text } from "@medusajs/ui"
import TransferActions from "@modules/order/components/transfer-actions"
import TransferImage from "@modules/order/components/transfer-image"
export default async function TransferPage({
params,
}: {
params: { id: string; token: string }
}) {
const { id, token } = params
return (
<div className="flex flex-col gap-y-4 items-start w-2/5 mx-auto mt-10 mb-20">
<TransferImage />
<div className="flex flex-col gap-y-6">
<Heading level="h1" className="text-xl text-zinc-900">
Transfer request for order {id}
</Heading>
<Text className="text-zinc-600">
You&#39;ve received a request to transfer ownership of your order ({id}).
If you agree to this request, you can approve the transfer by clicking
the button below.
</Text>
<div className="w-full h-px bg-zinc-200" />
<Text className="text-zinc-600">
If you accept, the new owner will take over all responsibilities and
permissions associated with this order.
</Text>
<Text className="text-zinc-600">
If you do not recognize this request or wish to retain ownership, no
further action is required.
</Text>
<div className="w-full h-px bg-zinc-200" />
<TransferActions id={id} token={token} />
</div>
</div>
)
}

View File

@ -0,0 +1,129 @@
import { Metadata } from "next"
import Image from "next/image"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import FeaturedProducts from "@modules/home/components/featured-products"
import Hero from "@modules/home/components/hero"
import { listCollections } from "@lib/data/collections"
import { listCategories } from "@lib/data/categories"
import { getRegion } from "@lib/data/regions"
import { BRAND_TAGLINE, pageTitle } from "@lib/brand"
export const metadata: Metadata = {
title: pageTitle(),
description: BRAND_TAGLINE,
}
const TILE_IMAGES = ["/apparel_collection.png", "/bags_collection.png"]
export default async function Home(props: {
params: Promise<{ countryCode: string }>
}) {
const params = await props.params
const { countryCode } = params
const region = await getRegion(countryCode)
const [{ collections }, categories] = await Promise.all([
listCollections({ fields: "id, handle, title" }),
listCategories().catch(() => []),
])
if (!region) {
// Without a region we cannot price anything; the middleware normally
// prevents this, so surface it rather than rendering a blank page.
throw new Error(
`No region found for country code "${countryCode}". Check the regions configured in Medusa.`
)
}
const tiles = (categories ?? [])
.filter((c) => !c.parent_category)
.slice(0, 4)
.map((category, idx) => ({
image: TILE_IMAGES[idx % TILE_IMAGES.length],
label: category.name,
href: `/categories/${category.handle}`,
}))
return (
<div className="bg-brand-bg min-h-screen pb-16">
{/* 1. Hero banner */}
<Hero />
{/* 2. Category Tile Grid */}
{tiles.length > 0 && (
<div className="content-container py-16 sm:py-24">
<div className="text-center mb-12">
<h2 className="font-serif text-3xl uppercase tracking-wider text-brand-primary">
Explore the selections
</h2>
<div className="w-12 h-[1px] bg-brand-primary mx-auto mt-4" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{tiles.map((tile) => (
<LocalizedClientLink
key={tile.href}
href={tile.href}
className="group flex flex-col gap-3 text-center"
>
<div className="relative aspect-[3/4] w-full overflow-hidden bg-brand-cream border border-brand-stone/30 rounded-none">
<Image
src={tile.image}
alt={tile.label}
fill
className="object-cover transition-transform duration-500 group-hover:scale-[1.04]"
sizes="(max-width: 768px) 50vw, 25vw"
/>
</div>
<span className="text-[11px] font-sans font-semibold tracking-widest text-brand-primary uppercase border-b border-transparent group-hover:border-brand-primary pb-1 self-center transition-all duration-200">
{tile.label}
</span>
</LocalizedClientLink>
))}
</div>
</div>
)}
{/* 3. Split Editorial Lookbook Banner */}
<div className="content-container pb-16 sm:pb-24">
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 items-stretch">
{/* Left Large Campaign Image */}
<div className="md:col-span-7 relative aspect-[4/3] md:aspect-auto min-h-[300px] bg-brand-cream border border-brand-stone/30 rounded-none overflow-hidden">
<Image
src="/apparel_collection.png"
alt="Editorial lookbook"
fill
className="object-cover object-center"
sizes="(max-width: 768px) 100vw, 60vw"
/>
</div>
{/* Right Text Block */}
<div className="md:col-span-5 bg-brand-cream border border-brand-stone/40 p-8 sm:p-12 flex flex-col justify-center gap-6 rounded-none">
<span className="text-[10px] tracking-[0.2em] font-sans font-semibold text-ui-fg-muted uppercase">
SEASON LOOKBOOK
</span>
<h3 className="font-serif text-3xl uppercase tracking-wider text-brand-primary">
The HOO Edit
</h3>
<p className="text-xs text-ui-fg-subtle leading-relaxed">
Discover pieces that redefine modern elegance with an avant-garde edge. Structured wool tailoring offset by lightweight sheer elements, curated for the modern wardrobe.
</p>
<LocalizedClientLink href="/collections" className="self-start">
<span className="inline-block border border-brand-primary bg-brand-primary text-brand-bg px-8 py-3 text-xs tracking-widest uppercase font-sans font-medium hover:bg-brand-onyx hover:border-brand-onyx transition-all duration-200 rounded-none">
Explore the collections
</span>
</LocalizedClientLink>
</div>
</div>
</div>
{/* 4. Product Rails */}
{collections?.length > 0 && (
<ul className="flex flex-col">
<FeaturedProducts collections={collections} region={region} />
</ul>
)}
</div>
)
}

View File

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest"
// Mock the data layer so this test never touches the Medusa backend or
// next/headers cookies (which only work inside a real request scope).
vi.mock("@lib/data/products", () => ({
listProducts: vi.fn(async () => ({
response: {
products: [
{
id: "prod_1",
title: "Test Product",
description: "A product used for SEO metadata testing",
thumbnail: "https://example.com/test-product.png",
variants: [],
images: [],
},
],
count: 1,
},
})),
}))
vi.mock("@lib/data/regions", () => ({
getRegion: vi.fn(async () => ({
id: "reg_1",
currency_code: "usd",
})),
listRegions: vi.fn(async () => []),
}))
// The rendered product page pulls in the full client component tree, which
// this test doesn't exercise — stub it out so only generateMetadata is under test.
vi.mock("@modules/products/templates", () => ({
default: () => null,
}))
import { pageTitle } from "@lib/brand"
describe("product page generateMetadata (SEO)", () => {
it("builds title, description and OG image from product data without a live backend", async () => {
const { generateMetadata } = await import("./page")
const metadata = await generateMetadata({
params: Promise.resolve({ countryCode: "us", handle: "test-product" }),
searchParams: Promise.resolve({}),
})
expect(metadata.title).toBe(pageTitle("Test Product"))
expect(metadata.description).toBe(
"A product used for SEO metadata testing"
)
expect(metadata.openGraph?.title).toBe(pageTitle("Test Product"))
expect(metadata.openGraph?.images).toEqual([
"https://example.com/test-product.png",
])
})
})

View File

@ -0,0 +1,169 @@
import { Metadata } from "next"
import { pageTitle } from "@lib/brand"
import { notFound } from "next/navigation"
import { listProducts } from "@lib/data/products"
import { getRegion, listRegions } from "@lib/data/regions"
import ProductTemplate from "@modules/products/templates"
import { HttpTypes } from "@medusajs/types"
type Props = {
params: Promise<{ countryCode: string; handle: string }>
searchParams: Promise<{ v_id?: string }>
}
export async function generateStaticParams() {
try {
const countryCodes = await listRegions().then((regions) =>
regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat()
)
if (!countryCodes) {
return []
}
const promises = countryCodes.map(async (country) => {
const { response } = await listProducts({
countryCode: country,
queryParams: { limit: 100, fields: "handle" },
})
return {
country,
products: response.products,
}
})
const countryProducts = await Promise.all(promises)
return countryProducts
.flatMap((countryData) =>
countryData.products.map((product) => ({
countryCode: countryData.country,
handle: product.handle,
}))
)
.filter((param) => param.handle)
} catch (error) {
console.error(
`Failed to generate static paths for product pages: ${
error instanceof Error ? error.message : "Unknown error"
}.`
)
return []
}
}
function getImagesForVariant(
product: HttpTypes.StoreProduct,
selectedVariantId?: string
): HttpTypes.StoreProductImage[] {
const productImages = product.images ?? []
if (!selectedVariantId || !product.variants) {
return productImages
}
const variant = product.variants.find((v) => v.id === selectedVariantId)
const variantImages = variant?.images ?? []
if (!variantImages.length) {
return productImages
}
const imageIdsMap = new Map(variantImages.map((i) => [i.id, true]))
const filtered = productImages.filter((i) => imageIdsMap.has(i.id))
// A variant may reference images that aren't on the product payload; falling
// back to the full set keeps the gallery from rendering empty.
return filtered.length ? filtered : productImages
}
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params
const { handle } = params
const region = await getRegion(params.countryCode)
if (!region) {
notFound()
}
const product = await listProducts({
countryCode: params.countryCode,
queryParams: { handle },
}).then(({ response }) => response.products[0])
if (!product) {
notFound()
}
const description = product.description || product.title
return {
title: pageTitle(product.title),
description,
openGraph: {
title: pageTitle(product.title),
description,
images: product.thumbnail ? [product.thumbnail] : [],
},
}
}
export default async function ProductPage(props: Props) {
const params = await props.params
const region = await getRegion(params.countryCode)
const searchParams = await props.searchParams
const selectedVariantId = searchParams.v_id
if (!region) {
notFound()
}
const pricedProduct = await listProducts({
countryCode: params.countryCode,
queryParams: { handle: params.handle },
}).then(({ response }) => response.products[0])
if (!pricedProduct) {
notFound()
}
const images = getImagesForVariant(pricedProduct, selectedVariantId)
const variantAmounts = (pricedProduct.variants ?? [])
.map((v) => v.calculated_price?.calculated_amount)
.filter((amount): amount is number => typeof amount === "number")
// Construct JSON-LD Structured Data
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
"name": pricedProduct.title,
"image": pricedProduct.thumbnail || (images && images[0]?.url) || "",
"description": pricedProduct.description || pricedProduct.title,
"sku": pricedProduct.variants?.[0]?.sku || pricedProduct.id,
"offers": {
"@type": "AggregateOffer",
"priceCurrency": region.currency_code?.toUpperCase() || "USD",
"lowPrice": variantAmounts.length ? Math.min(...variantAmounts) : 0,
"highPrice": variantAmounts.length ? Math.max(...variantAmounts) : 0,
"offerCount": pricedProduct.variants?.length || 1,
}
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProductTemplate
product={pricedProduct}
region={region}
countryCode={params.countryCode}
images={images}
/>
</>
)
}

View File

@ -0,0 +1,33 @@
import { Metadata } from "next"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import StoreTemplate from "@modules/store/templates"
export const metadata: Metadata = {
title: "Store",
description: "Explore all of our products.",
}
type Params = {
searchParams: Promise<{
sortBy?: SortOptions
page?: string
}>
params: Promise<{
countryCode: string
}>
}
export default async function StorePage(props: Params) {
const params = await props.params;
const searchParams = await props.searchParams;
const { sortBy, page } = searchParams
return (
<StoreTemplate
sortBy={sortBy}
page={page}
countryCode={params.countryCode}
/>
)
}

View File

@ -0,0 +1,157 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { useWishlist } from "@lib/context/wishlist-context"
import { listProducts } from "@lib/data/products"
import { getProductPrice } from "@lib/util/get-product-price"
import { HttpTypes } from "@medusajs/types"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import Thumbnail from "@modules/products/components/thumbnail"
export default function WishlistPage() {
const { countryCode } = useParams() as { countryCode: string }
const { wishlist, isHydrated, removeFromWishlist } = useWishlist()
const [products, setProducts] = useState<HttpTypes.StoreProduct[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
// Wait for localStorage to be read, otherwise the first pass always looks
// like an empty wishlist and flashes the empty state.
if (!isHydrated) {
return
}
if (wishlist.length === 0) {
setProducts([])
setError(null)
return
}
let cancelled = false
setIsLoading(true)
setError(null)
// Goes through the server action rather than the browser SDK: the backend
// URL is a server-only env var, so a client-side SDK call would always
// fall back to localhost.
listProducts({
countryCode,
queryParams: { id: wishlist, limit: wishlist.length },
})
.then(({ response }) => {
if (cancelled) {
return
}
setProducts(response.products ?? [])
})
.catch((err) => {
if (cancelled) {
return
}
console.error("Failed to fetch wishlist products:", err)
setError("Could not load your wishlist. Please try again shortly.")
})
.finally(() => {
if (!cancelled) {
setIsLoading(false)
}
})
return () => {
cancelled = true
}
}, [wishlist, countryCode, isHydrated])
const showEmptyState =
isHydrated && !isLoading && !error && wishlist.length === 0
return (
<div className="content-container py-12 min-h-[60vh] flex flex-col items-center">
<h1 className="font-serif text-3xl uppercase tracking-wider text-brand-primary mb-12 text-center">
My Wishlist
</h1>
{(!isHydrated || isLoading) && (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-primary" />
<p className="text-xs text-ui-fg-muted tracking-[0.1em]">
LOADING WISHLIST...
</p>
</div>
)}
{!isLoading && error && (
<div className="text-center py-16 max-w-md flex flex-col items-center gap-4">
<p className="text-sm text-ui-fg-subtle">{error}</p>
<LocalizedClientLink
href="/store"
className="text-xs font-semibold underline hover:text-ui-fg-muted tracking-[0.1em] uppercase"
>
Continue shopping
</LocalizedClientLink>
</div>
)}
{showEmptyState && (
<div className="text-center py-24 flex flex-col items-center gap-4">
<p className="text-xs text-ui-fg-subtle tracking-[0.1em]">
YOUR WISHLIST IS CURRENTLY EMPTY.
</p>
<LocalizedClientLink
href="/store"
className="text-xs font-semibold underline hover:text-ui-fg-muted tracking-[0.1em] uppercase"
>
Discover the collections
</LocalizedClientLink>
</div>
)}
{!isLoading && !error && products.length > 0 && (
<ul className="grid grid-cols-2 small:grid-cols-4 gap-x-6 gap-y-10 w-full">
{products.map((product) => {
const price = getProductPrice({ product }).cheapestPrice
return (
<li key={product.id} className="group relative flex flex-col">
<LocalizedClientLink
href={`/products/${product.handle}`}
className="flex-1"
>
<Thumbnail
thumbnail={product.thumbnail}
images={product.images}
size="full"
/>
<div className="flex flex-col items-center text-center gap-1 mt-4">
<span
className="text-xs font-semibold font-serif uppercase tracking-wider text-brand-primary group-hover:underline"
data-testid="product-title"
>
{product.title}
</span>
{price && (
<span className="text-[11px] text-brand-primary font-sans">
{price.calculated_price}
</span>
)}
</div>
</LocalizedClientLink>
<button
type="button"
onClick={() => removeFromWishlist(product.id!)}
className="mt-3 text-center text-[10px] tracking-[0.1em] font-semibold text-ui-fg-muted hover:text-black transition-colors uppercase border-t border-brand-stone/40 pt-2"
>
Remove from wishlist
</button>
</li>
)
})}
</ul>
)}
</div>
)
}

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

49
src/app/layout.tsx Normal file
View File

@ -0,0 +1,49 @@
import { Metadata } from "next"
import { Bodoni_Moda, Work_Sans } from "next/font/google"
import { BRAND_TAGLINE, pageTitle } from "@lib/brand"
import { WishlistProvider } from "@lib/context/wishlist-context"
import { getBaseURL } from "@lib/util/env"
import "styles/globals.css"
const bodoniModa = Bodoni_Moda({
subsets: ["latin"],
variable: "--font-bodoni-moda",
display: "swap",
})
const workSans = Work_Sans({
subsets: ["latin"],
variable: "--font-work-sans",
display: "swap",
})
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
title: {
default: pageTitle(),
template: "%s",
},
description: BRAND_TAGLINE,
icons: {
icon: "/icon.png",
shortcut: "/icon.png",
apple: "/icon.png",
},
}
export default function RootLayout(props: { children: React.ReactNode }) {
return (
<html
lang="en"
data-mode="light"
className={`${bodoniModa.variable} ${workSans.variable}`}
>
<body className="bg-brand-bg text-brand-primary antialiased">
<WishlistProvider>
<main className="relative">{props.children}</main>
</WishlistProvider>
</body>
</html>
)
}

30
src/app/not-found.tsx Normal file
View File

@ -0,0 +1,30 @@
import { ArrowUpRightMini } from "@medusajs/icons"
import { Text } from "@medusajs/ui"
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[calc(100vh-64px)]">
<h1 className="text-2xl-semi text-ui-fg-base">Page not found</h1>
<p className="text-small-regular text-ui-fg-base">
The page you tried to access does not exist.
</p>
<Link
className="flex gap-x-1 items-center group"
href="/"
>
<Text className="text-ui-fg-interactive">Go to frontpage</Text>
<ArrowUpRightMini
className="group-hover:rotate-45 ease-in-out duration-150"
color="var(--fg-interactive)"
/>
</Link>
</div>
)
}

BIN
src/app/opengraph-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

19
src/app/robots.ts Normal file
View File

@ -0,0 +1,19 @@
import { MetadataRoute } from "next"
import { getBaseURL } from "@lib/util/env"
export default function robots(): MetadataRoute.Robots {
const baseUrl = getBaseURL()
return {
rules: {
userAgent: "*",
allow: "/",
disallow: [
"/*/cart",
"/*/checkout",
"/*/account",
],
},
sitemap: `${baseUrl}/sitemap.xml`,
}
}

62
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,62 @@
import { MetadataRoute } from "next"
import { listProducts } from "@lib/data/products"
import { listCollections } from "@lib/data/collections"
import { listCategories } from "@lib/data/categories"
import { getBaseURL } from "@lib/util/env"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseURL()
const defaultCountry = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
const routes = [
"",
"/store",
"/cart",
"/account",
"/wishlist",
].map((route) => ({
url: `${baseUrl}/${defaultCountry}${route}`,
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: route === "" ? 1.0 : 0.8,
}))
try {
// Fetch products
const { response } = await listProducts({
countryCode: defaultCountry,
queryParams: { limit: 100, fields: "handle,updated_at" }
})
const productRoutes = (response.products || []).map((product) => ({
url: `${baseUrl}/${defaultCountry}/products/${product.handle}`,
lastModified: product.updated_at ? new Date(product.updated_at) : new Date(),
changeFrequency: "weekly" as const,
priority: 0.7,
}))
// Fetch collections
const { collections } = await listCollections({
fields: "handle,updated_at"
})
const collectionRoutes = (collections || []).map((collection) => ({
url: `${baseUrl}/${defaultCountry}/collections/${collection.handle}`,
lastModified: collection.updated_at ? new Date(collection.updated_at) : new Date(),
changeFrequency: "weekly" as const,
priority: 0.6,
}))
// Fetch categories
const categories = await listCategories()
const categoryRoutes = (categories || []).map((category: any) => ({
url: `${baseUrl}/${defaultCountry}/categories/${category.handle}`,
lastModified: category.updated_at ? new Date(category.updated_at) : new Date(),
changeFrequency: "weekly" as const,
priority: 0.6,
}))
return [...routes, ...productRoutes, ...collectionRoutes, ...categoryRoutes]
} catch (error) {
console.error("Failed to generate sitemap dynamically:", error)
return routes
}
}

BIN
src/app/twitter-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

30
src/lib/brand.ts Normal file
View File

@ -0,0 +1,30 @@
/**
* Single source of truth for storefront branding.
*
* The UI reference in `docs/implementation-clone-miumiu-plan.md` clones Miu
* Miu's *layout and interaction design only* — names, copy and imagery must be
* our own. Everything user-visible that identifies the store lives here, so
* rebranding is a one-file change and no third-party name can leak back into
* the markup.
*/
/** Wordmark shown in the header and used in page titles. */
export const BRAND_NAME = "HOO Store"
/** Lowercase slug used for storage keys, cookies and other machine-facing ids. */
export const BRAND_SLUG = "hoo-store"
/** Tagline used as the default meta description. */
export const BRAND_TAGLINE =
"Ready-to-wear, bags, shoes and accessories from the HOO Store seasonal collections."
/** Builds a consistent `<title>` across every route. */
export const pageTitle = (page?: string) =>
page ? `${page} | ${BRAND_NAME}` : BRAND_NAME
/**
* Social profiles rendered in the footer. Empty by default — the section is
* hidden until real profile URLs are filled in, so the footer never ships
* links that go nowhere.
*/
export const BRAND_SOCIAL_LINKS: { label: string; href: string }[] = []

39
src/lib/config.ts Normal file
View File

@ -0,0 +1,39 @@
import { getLocaleHeader } from "@lib/util/get-locale-header"
import Medusa, { FetchArgs, FetchInput } from "@medusajs/js-sdk"
// Defaults to standard port for Medusa server
let MEDUSA_BACKEND_URL = "http://localhost:9000"
if (process.env.MEDUSA_BACKEND_URL) {
MEDUSA_BACKEND_URL = process.env.MEDUSA_BACKEND_URL
}
export const sdk = new Medusa({
baseUrl: MEDUSA_BACKEND_URL,
debug: process.env.NODE_ENV === "development",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})
const originalFetch = sdk.client.fetch.bind(sdk.client)
sdk.client.fetch = async <T>(
input: FetchInput,
init?: FetchArgs
): Promise<T> => {
const headers = init?.headers ?? {}
let localeHeader: Record<string, string | null> | undefined
try {
localeHeader = await getLocaleHeader()
headers["x-medusa-locale"] ??= localeHeader["x-medusa-locale"]
} catch {}
const newHeaders = {
...localeHeader,
...headers,
}
init = {
...init,
headers: newHeaders,
}
return originalFetch(input, init)
}

75
src/lib/constants.tsx Normal file
View File

@ -0,0 +1,75 @@
import React from "react"
import { CreditCard } from "@medusajs/icons"
import Ideal from "@modules/common/icons/ideal"
import Bancontact from "@modules/common/icons/bancontact"
import PayPal from "@modules/common/icons/paypal"
/* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */
export const paymentInfoMap: Record<
string,
{ title: string; icon: React.JSX.Element }
> = {
pp_stripe_stripe: {
title: "Credit card",
icon: <CreditCard />,
},
"pp_medusa-payments_default": {
title: "Credit card",
icon: <CreditCard />,
},
"pp_stripe-ideal_stripe": {
title: "iDeal",
icon: <Ideal />,
},
"pp_stripe-bancontact_stripe": {
title: "Bancontact",
icon: <Bancontact />,
},
pp_paypal_paypal: {
title: "PayPal",
icon: <PayPal />,
},
pp_system_default: {
title: "Manual Payment",
icon: <CreditCard />,
},
// Add more payment providers here
}
// This only checks if it is native stripe or medusa payments for card payments, it ignores the other stripe-based providers
export const isStripeLike = (providerId?: string) => {
return (
providerId?.startsWith("pp_stripe_") || providerId?.startsWith("pp_medusa-")
)
}
export const isPaypal = (providerId?: string) => {
return providerId?.startsWith("pp_paypal")
}
export const isManual = (providerId?: string) => {
return providerId?.startsWith("pp_system_default")
}
// Add currencies that don't need to be divided by 100
export const noDivisionCurrencies = [
"krw",
"jpy",
"vnd",
"clp",
"pyg",
"xaf",
"xof",
"bif",
"djf",
"gnf",
"kmf",
"mga",
"rwf",
"xpf",
"htg",
"vuv",
"xag",
"xdr",
"xau",
]

View File

@ -0,0 +1,264 @@
import { BRAND_NAME } from "@lib/brand"
export type InfoSection = {
heading: string
body: string[]
}
export type InfoPage = {
handle: string
title: string
intro: string
sections: InfoSection[]
}
/**
* Static informational pages linked from the footer.
*
* These exist so the footer directory links to real routes instead of dead
* `#` anchors. Copy is intentionally generic boilerplate — replace it (or
* swap this module for a CMS query) before launch.
*/
export const INFO_PAGES: InfoPage[] = [
{
handle: "contact",
title: "Contact Us",
intro: `Our client advisors are available Monday to Saturday, 9:00–19:00.`,
sections: [
{
heading: "Client service",
body: [
"Email us and we will respond within one business day.",
"For order-specific questions, please have your order number ready.",
],
},
{
heading: "Press and partnerships",
body: [
`For press enquiries, collaborations and wholesale, contact the ${BRAND_NAME} communications team.`,
],
},
],
},
{
handle: "shipping",
title: "Shipping & Delivery",
intro: "Complimentary standard shipping on every order.",
sections: [
{
heading: "Delivery times",
body: [
"Standard delivery arrives within 3–5 business days.",
"Express delivery arrives within 1–2 business days where available.",
],
},
{
heading: "Order tracking",
body: [
"A tracking link is emailed as soon as your order leaves our warehouse.",
"You can also follow your order from your account at any time.",
],
},
],
},
{
handle: "returns",
title: "Returns & Exchanges",
intro: "Items can be returned within 30 days of delivery.",
sections: [
{
heading: "How to return",
body: [
"Request a return from your account and print the prepaid label.",
"Items must be unworn, with all tags and original packaging intact.",
],
},
{
heading: "Refunds",
body: [
"Refunds are issued to the original payment method once the return is received and inspected.",
"Please allow up to 10 business days for the refund to appear.",
],
},
],
},
{
handle: "faq",
title: "FAQs",
intro: "Answers to the questions we are asked most often.",
sections: [
{
heading: "Do you ship internationally?",
body: [
"Yes. Available destinations and duties are shown at checkout based on the region you select.",
],
},
{
heading: "Can I change my order after placing it?",
body: [
"Orders can be amended while they are still being prepared. Contact client service as soon as possible.",
],
},
{
heading: "How do I care for my pieces?",
body: [
"Care instructions are listed on each product page and on the garment label.",
],
},
],
},
{
handle: "size-guide",
title: "Size Guide",
intro: "Measurements are given in centimetres and refer to body size.",
sections: [
{
heading: "Ready-to-wear",
body: [
"XS 32–34 · S 36 · M 38–40 · L 42 · XL 44",
"If you are between sizes, we recommend sizing up for outerwear.",
],
},
{
heading: "Made to measure",
body: [
"Selected pieces accept custom measurements at checkout. Enter each measurement in centimetres.",
],
},
],
},
{
handle: "product-care",
title: "Product Care",
intro: "Looking after your pieces so they last.",
sections: [
{
heading: "Leather goods",
body: [
"Store in the dust bag provided, away from direct sunlight and heat.",
"Wipe gently with a soft, dry cloth. Avoid solvents and detergents.",
],
},
{
heading: "Knitwear and tailoring",
body: [
"Fold knitwear rather than hanging it to preserve the shape.",
"Dry clean tailoring only.",
],
},
],
},
{
handle: "about",
title: "About Us",
intro: `${BRAND_NAME} is an independent ready-to-wear and leather goods house.`,
sections: [
{
heading: "The house",
body: [
"We design and produce in small, considered runs, working with a fixed group of ateliers.",
"Each collection is built around a single idea rather than a seasonal calendar.",
],
},
{
heading: "Craft",
body: [
"Materials are selected for how they age, not only how they look on the first day.",
],
},
],
},
{
handle: "sustainability",
title: "Sustainability",
intro: "How we think about materials, production and longevity.",
sections: [
{
heading: "Materials",
body: [
"We prioritise certified and traceable materials wherever a suitable option exists.",
],
},
{
heading: "Production",
body: [
"Small production runs reduce overstock. Unsold stock is reworked rather than destroyed.",
],
},
],
},
{
handle: "careers",
title: "Work With Us",
intro: "Open roles across design, production, retail and technology.",
sections: [
{
heading: "How we hire",
body: [
"We review every application and reply either way.",
"Portfolios are welcome for design and creative roles.",
],
},
],
},
{
handle: "privacy",
title: "Privacy Policy",
intro: "How we collect, use and protect your personal data.",
sections: [
{
heading: "Data we collect",
body: [
"Account details, order history, delivery addresses and payment metadata required to fulfil your order.",
],
},
{
heading: "Your rights",
body: [
"You may request access to, correction of, or deletion of your personal data at any time.",
],
},
],
},
{
handle: "terms",
title: "Terms of Sale",
intro: "The terms that apply to purchases made through this store.",
sections: [
{
heading: "Orders",
body: [
"An order is accepted when we send the shipping confirmation, not at the point of payment.",
],
},
{
heading: "Pricing",
body: [
"Prices are shown in the currency of the region you select and include applicable taxes where indicated.",
],
},
],
},
{
handle: "cookies",
title: "Cookie Policy",
intro: "Which cookies this site uses and why.",
sections: [
{
heading: "Essential cookies",
body: [
"Required to keep you signed in and to remember the contents of your bag.",
],
},
{
heading: "Analytics",
body: [
"Used in aggregate to understand which pages are useful. No analytics cookies are set without consent.",
],
},
],
},
]
export const getInfoPage = (handle: string) =>
INFO_PAGES.find((page) => page.handle === handle)

View File

@ -0,0 +1,34 @@
"use client"
import React, { createContext, useContext } from "react"
interface ModalContext {
close: () => void
}
const ModalContext = createContext<ModalContext | null>(null)
interface ModalProviderProps {
children?: React.ReactNode
close: () => void
}
export const ModalProvider = ({ children, close }: ModalProviderProps) => {
return (
<ModalContext.Provider
value={{
close,
}}
>
{children}
</ModalContext.Provider>
)
}
export const useModal = () => {
const context = useContext(ModalContext)
if (context === null) {
throw new Error("useModal must be used within a ModalProvider")
}
return context
}

View File

@ -0,0 +1,94 @@
"use client"
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { BRAND_SLUG } from "@lib/brand"
const STORAGE_KEY = `${BRAND_SLUG}:wishlist`
type WishlistContextType = {
wishlist: string[]
/** False until localStorage has been read, so consumers can avoid flashing an empty state. */
isHydrated: boolean
addToWishlist: (id: string) => void
removeFromWishlist: (id: string) => void
isLiked: (id: string) => boolean
}
const WishlistContext = createContext<WishlistContextType | undefined>(undefined)
export const WishlistProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [wishlist, setWishlist] = useState<string[]>([])
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
try {
const saved = window.localStorage.getItem(STORAGE_KEY)
if (saved) {
const parsed = JSON.parse(saved)
if (Array.isArray(parsed)) {
setWishlist(parsed.filter((id): id is string => typeof id === "string"))
}
}
} catch (e) {
console.error("Failed to parse wishlist from local storage", e)
} finally {
setIsHydrated(true)
}
}, [])
// Persist as an effect rather than inside a state updater: updaters must stay
// pure, and under StrictMode they run twice.
useEffect(() => {
if (!isHydrated) {
return
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(wishlist))
} catch (e) {
console.error("Failed to persist wishlist to local storage", e)
}
}, [wishlist, isHydrated])
const addToWishlist = useCallback((id: string) => {
setWishlist((prev) => (prev.includes(id) ? prev : [...prev, id]))
}, [])
const removeFromWishlist = useCallback((id: string) => {
setWishlist((prev) => prev.filter((item) => item !== id))
}, [])
const isLiked = useCallback(
(id: string) => wishlist.includes(id),
[wishlist]
)
const value = useMemo(
() => ({ wishlist, isHydrated, addToWishlist, removeFromWishlist, isLiked }),
[wishlist, isHydrated, addToWishlist, removeFromWishlist, isLiked]
)
return (
<WishlistContext.Provider value={value}>
{children}
</WishlistContext.Provider>
)
}
export const useWishlist = () => {
const context = useContext(WishlistContext)
if (context === undefined) {
throw new Error("useWishlist must be used within a WishlistProvider")
}
return context
}

524
src/lib/data/cart.ts Normal file
View File

@ -0,0 +1,524 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeCartId,
setCartId,
} from "./cookies"
import { getRegion } from "./regions"
import { getLocale } from "@lib/data/locale-actions"
/**
* Retrieves a cart by its ID. If no ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to retrieve.
* @returns The cart object if found, or null if not found.
*/
export async function retrieveCart(cartId?: string, fields?: string) {
const id = cartId || (await getCartId())
fields ??=
"*items, *region, *items.product, *items.variant, *items.thumbnail, *items.metadata, +items.total, *promotions, +shipping_methods.name"
if (!id) {
return null
}
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("carts")),
}
return await sdk.client
.fetch<HttpTypes.StoreCartResponse>(`/store/carts/${id}`, {
method: "GET",
query: {
fields,
},
headers,
next,
cache: "force-cache",
})
.then(({ cart }: { cart: HttpTypes.StoreCart }) => cart)
.catch(() => null)
}
export async function getOrSetCart(countryCode: string) {
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
let cart = await retrieveCart(undefined, "id,region_id")
const headers = {
...(await getAuthHeaders()),
}
if (!cart) {
const locale = await getLocale()
const cartResp = await sdk.store.cart.create(
{ region_id: region.id, locale: locale || undefined },
{},
headers
)
cart = cartResp.cart
await setCartId(cart.id)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
if (cart && cart?.region_id !== region.id) {
await sdk.store.cart.update(cart.id, { region_id: region.id }, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
return cart
}
export async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found, please create one before updating")
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.update(cartId, data, {}, headers)
.then(async ({ cart }: { cart: HttpTypes.StoreCart }) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
return cart
})
.catch(medusaError)
}
export async function addToCart({
variantId,
quantity,
countryCode,
metadata,
}: {
variantId: string
quantity: number
countryCode: string
metadata?: Record<string, any>
}) {
if (!variantId) {
throw new Error("Missing variant ID when adding to cart")
}
const cart = await getOrSetCart(countryCode)
if (!cart) {
throw new Error("Error retrieving or creating cart")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.createLineItem(
cart.id,
{
variant_id: variantId,
quantity,
metadata,
},
{},
headers
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function addBespokeToCart({
variantId,
quantity,
countryCode,
metadata,
}: {
variantId: string
quantity: number
countryCode: string
metadata?: Record<string, any>
}) {
if (!variantId) {
throw new Error("Missing variant ID when adding bespoke item to cart")
}
const cart = await getOrSetCart(countryCode)
if (!cart) {
throw new Error("Error retrieving or creating cart")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.client
.fetch<{ cart: HttpTypes.StoreCart }>(
`/store/carts/${cart.id}/bespoke-line-item`,
{
method: "POST",
body: {
variant_id: variantId,
quantity,
metadata,
},
headers,
}
)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function updateLineItem({
lineId,
quantity,
}: {
lineId: string
quantity: number
}) {
if (!lineId) {
throw new Error("Missing lineItem ID when updating line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when updating line item")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.updateLineItem(cartId, lineId, { quantity }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function deleteLineItem(lineId: string) {
if (!lineId) {
throw new Error("Missing lineItem ID when deleting line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when deleting line item")
}
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart
.deleteLineItem(cartId, lineId, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function setShippingMethod({
cartId,
shippingMethodId,
}: {
cartId: string
shippingMethodId: string
}) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.addShippingMethod(cartId, { option_id: shippingMethodId }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
})
.catch(medusaError)
}
export async function initiatePaymentSession(
cart: HttpTypes.StoreCart,
data: HttpTypes.StoreInitializePaymentSession
) {
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.payment
.initiatePaymentSession(cart, data, {}, headers)
.then(async (resp) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
return resp
})
.catch(medusaError)
}
export async function applyPromotions(codes: string[]) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found")
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.cart
.update(cartId, { promo_codes: codes }, {}, headers)
.then(async () => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
const fulfillmentCacheTag = await getCacheTag("fulfillment")
revalidateTag(fulfillmentCacheTag)
})
.catch(medusaError)
}
export async function applyGiftCard(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, { gift_cards: [{ code }] }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function removeDiscount(code: string) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await deleteDiscount(cartId, code)
// revalidateTag("cart")
// } catch (error: any) {
// throw error
// }
}
export async function removeGiftCard(
codeToRemove: string,
giftCards: any[]
// giftCards: GiftCard[]
) {
// const cartId = getCartId()
// if (!cartId) return "No cartId cookie found"
// try {
// await updateCart(cartId, {
// gift_cards: [...giftCards]
// .filter((gc) => gc.code !== codeToRemove)
// .map((gc) => ({ code: gc.code })),
// }).then(() => {
// revalidateTag("cart")
// })
// } catch (error: any) {
// throw error
// }
}
export async function submitPromotionForm(
currentState: unknown,
formData: FormData
) {
const code = formData.get("code") as string
try {
await applyPromotions([code])
} catch (e: any) {
return e.message
}
}
// TODO: Pass a POJO instead of a form entity here
export async function setAddresses(currentState: unknown, formData: FormData) {
try {
if (!formData) {
throw new Error("No form data found when setting addresses")
}
const cartId = getCartId()
if (!cartId) {
throw new Error("No existing cart found when setting addresses")
}
const data = {
shipping_address: {
first_name: formData.get("shipping_address.first_name"),
last_name: formData.get("shipping_address.last_name"),
address_1: formData.get("shipping_address.address_1"),
address_2: "",
company: formData.get("shipping_address.company"),
postal_code: formData.get("shipping_address.postal_code"),
city: formData.get("shipping_address.city"),
country_code: formData.get("shipping_address.country_code"),
province: formData.get("shipping_address.province"),
phone: formData.get("shipping_address.phone"),
},
email: formData.get("email"),
} as any
const sameAsBilling = formData.get("same_as_billing")
if (sameAsBilling === "on") data.billing_address = data.shipping_address
if (sameAsBilling !== "on")
data.billing_address = {
first_name: formData.get("billing_address.first_name"),
last_name: formData.get("billing_address.last_name"),
address_1: formData.get("billing_address.address_1"),
address_2: "",
company: formData.get("billing_address.company"),
postal_code: formData.get("billing_address.postal_code"),
city: formData.get("billing_address.city"),
country_code: formData.get("billing_address.country_code"),
province: formData.get("billing_address.province"),
phone: formData.get("billing_address.phone"),
}
await updateCart(data)
} catch (e: any) {
return e.message
}
redirect(
`/${formData.get("shipping_address.country_code")}/checkout?step=delivery`
)
}
/**
* Places an order for a cart. If no cart ID is provided, it will use the cart ID from the cookies.
* @param cartId - optional - The ID of the cart to place an order for.
* @returns The cart object if the order was successful, or null if not.
*/
export async function placeOrder(cartId?: string) {
const id = cartId || (await getCartId())
if (!id) {
throw new Error("No existing cart found when placing an order")
}
const headers = {
...(await getAuthHeaders()),
}
const cartRes = await sdk.store.cart
.complete(id, {}, headers)
.then(async (cartRes) => {
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
return cartRes
})
.catch(medusaError)
if (cartRes?.type === "order") {
const countryCode =
cartRes.order.shipping_address?.country_code?.toLowerCase()
const orderCacheTag = await getCacheTag("orders")
revalidateTag(orderCacheTag)
removeCartId()
redirect(`/${countryCode}/order/${cartRes?.order.id}/confirmed`)
}
return cartRes.cart
}
/**
* Updates the countrycode param and revalidates the regions cache
* @param regionId
* @param countryCode
*/
export async function updateRegion(countryCode: string, currentPath: string) {
const cartId = await getCartId()
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
if (cartId) {
await updateCart({ region_id: region.id })
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
const regionCacheTag = await getCacheTag("regions")
revalidateTag(regionCacheTag)
const productsCacheTag = await getCacheTag("products")
revalidateTag(productsCacheTag)
redirect(`/${countryCode}${currentPath}`)
}
export async function listCartOptions() {
const cartId = await getCartId()
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("shippingOptions")),
}
return await sdk.client.fetch<{
shipping_options: HttpTypes.StoreCartShippingOption[]
}>("/store/shipping-options", {
query: { cart_id: cartId },
next,
headers,
cache: "force-cache",
})
}

View File

@ -0,0 +1,49 @@
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listCategories = async (query?: Record<string, any>) => {
const next = {
...(await getCacheOptions("categories")),
}
const limit = query?.limit || 100
return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
"/store/product-categories",
{
query: {
fields:
"*category_children, *products, *parent_category, *parent_category.parent_category, metadata",
limit,
...query,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories)
}
export const getCategoryByHandle = async (categoryHandle: string[]) => {
const handle = `${categoryHandle.join("/")}`
const next = {
...(await getCacheOptions("categories")),
}
return sdk.client
.fetch<HttpTypes.StoreProductCategoryListResponse>(
`/store/product-categories`,
{
query: {
fields: "*category_children, *products",
handle,
},
next,
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories[0])
}

View File

@ -0,0 +1,59 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const retrieveCollection = async (id: string) => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<{ collection: HttpTypes.StoreCollection }>(
`/store/collections/${id}`,
{
next,
cache: "force-cache",
}
)
.then(({ collection }) => collection)
}
export const listCollections = async (
queryParams: Record<string, string> = {}
): Promise<{ collections: HttpTypes.StoreCollection[]; count: number }> => {
const next = {
...(await getCacheOptions("collections")),
}
queryParams.limit = queryParams.limit || "100"
queryParams.offset = queryParams.offset || "0"
return sdk.client
.fetch<{ collections: HttpTypes.StoreCollection[]; count: number }>(
"/store/collections",
{
query: queryParams,
next,
cache: "force-cache",
}
)
.then(({ collections }) => ({ collections, count: collections.length }))
}
export const getCollectionByHandle = async (
handle: string
): Promise<HttpTypes.StoreCollection> => {
const next = {
...(await getCacheOptions("collections")),
}
return sdk.client
.fetch<HttpTypes.StoreCollectionListResponse>(`/store/collections`, {
query: { handle, fields: "*products" },
next,
cache: "force-cache",
})
.then(({ collections }) => collections[0])
}

89
src/lib/data/cookies.ts Normal file
View File

@ -0,0 +1,89 @@
import "server-only"
import { cookies as nextCookies } from "next/headers"
export const getAuthHeaders = async (): Promise<
{ authorization: string } | {}
> => {
try {
const cookies = await nextCookies()
const token = cookies.get("_medusa_jwt")?.value
if (!token) {
return {}
}
return { authorization: `Bearer ${token}` }
} catch {
return {}
}
}
export const getCacheTag = async (tag: string): Promise<string> => {
try {
const cookies = await nextCookies()
const cacheId = cookies.get("_medusa_cache_id")?.value
if (!cacheId) {
return ""
}
return `${tag}-${cacheId}`
} catch (error) {
return ""
}
}
export const getCacheOptions = async (
tag: string
): Promise<{ tags: string[]; revalidate?: number } | {}> => {
if (typeof window !== "undefined") {
return {}
}
const cacheTag = await getCacheTag(tag)
if (!cacheTag) {
return { tags: [tag], revalidate: 5 }
}
return { tags: [`${cacheTag}`, tag], revalidate: 5 }
}
export const setAuthToken = async (token: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", token, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeAuthToken = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_jwt", "", {
maxAge: -1,
})
}
export const getCartId = async () => {
const cookies = await nextCookies()
return cookies.get("_medusa_cart_id")?.value
}
export const setCartId = async (cartId: string) => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", cartId, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeCartId = async () => {
const cookies = await nextCookies()
cookies.set("_medusa_cart_id", "", {
maxAge: -1,
})
}

272
src/lib/data/customer.ts Normal file
View File

@ -0,0 +1,272 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import {
getAuthHeaders,
getCacheOptions,
getCacheTag,
getCartId,
removeAuthToken,
removeCartId,
setAuthToken,
} from "./cookies"
export const retrieveCustomer =
async (): Promise<HttpTypes.StoreCustomer | null> => {
const authHeaders = await getAuthHeaders()
if (!authHeaders) return null
const headers = {
...authHeaders,
}
const next = {
...(await getCacheOptions("customers")),
}
return await sdk.client
.fetch<{ customer: HttpTypes.StoreCustomer }>(`/store/customers/me`, {
method: "GET",
query: {
fields: "*orders",
},
headers,
next,
cache: "force-cache",
})
.then(({ customer }) => customer)
.catch(() => null)
}
export const updateCustomer = async (body: HttpTypes.StoreUpdateCustomer) => {
const headers = {
...(await getAuthHeaders()),
}
const updateRes = await sdk.store.customer
.update(body, {}, headers)
.then(({ customer }) => customer)
.catch(medusaError)
const cacheTag = await getCacheTag("customers")
revalidateTag(cacheTag)
return updateRes
}
export async function signup(_currentState: unknown, formData: FormData) {
const password = formData.get("password") as string
const customerForm = {
email: formData.get("email") as string,
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
phone: formData.get("phone") as string,
}
try {
const token = await sdk.auth.register("customer", "emailpass", {
email: customerForm.email,
password: password,
})
await setAuthToken(token as string)
const headers = {
...(await getAuthHeaders()),
}
const { customer: createdCustomer } = await sdk.store.customer.create(
customerForm,
{},
headers
)
const loginToken = await sdk.auth.login("customer", "emailpass", {
email: customerForm.email,
password,
})
await setAuthToken(loginToken as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await transferCart()
return createdCustomer
} catch (error: any) {
return error.toString()
}
}
export async function login(_currentState: unknown, formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
try {
await sdk.auth
.login("customer", "emailpass", { email, password })
.then(async (token) => {
await setAuthToken(token as string)
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
})
} catch (error: any) {
return error.toString()
}
try {
await transferCart()
} catch (error: any) {
return error.toString()
}
}
export async function signout(countryCode: string) {
await sdk.auth.logout()
await removeAuthToken()
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
await removeCartId()
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
redirect(`/${countryCode}/account`)
}
export async function transferCart() {
const cartId = await getCartId()
if (!cartId) {
return
}
const headers = await getAuthHeaders()
await sdk.store.cart.transferCart(cartId, {}, headers)
const cartCacheTag = await getCacheTag("carts")
revalidateTag(cartCacheTag)
}
export const addCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const isDefaultBilling = (currentState.isDefaultBilling as boolean) || false
const isDefaultShipping = (currentState.isDefaultShipping as boolean) || false
// The add-address form has no name fields: the recipient name is taken from
// the customer profile (editable later via the address "Edit" form).
const customer = await retrieveCustomer()
const address = {
first_name:
(formData.get("first_name") as string | null) ??
customer?.first_name ??
"",
last_name:
(formData.get("last_name") as string | null) ??
customer?.last_name ??
"",
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
phone:
(formData.get("phone") as string | null) || customer?.phone || "",
is_default_billing: isDefaultBilling,
is_default_shipping: isDefaultShipping,
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.createAddress(address, {}, headers)
.then(async ({ customer }) => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const deleteCustomerAddress = async (
addressId: string
): Promise<void> => {
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.customer
.deleteAddress(addressId, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}
export const updateCustomerAddress = async (
currentState: Record<string, unknown>,
formData: FormData
): Promise<any> => {
const addressId =
(currentState.addressId as string) || (formData.get("addressId") as string)
if (!addressId) {
return { success: false, error: "Address ID is required" }
}
const address = {
first_name: formData.get("first_name") as string,
last_name: formData.get("last_name") as string,
company: formData.get("company") as string,
address_1: formData.get("address_1") as string,
address_2: formData.get("address_2") as string,
city: formData.get("city") as string,
postal_code: formData.get("postal_code") as string,
province: formData.get("province") as string,
country_code: formData.get("country_code") as string,
} as HttpTypes.StoreUpdateCustomerAddress
const phone = formData.get("phone") as string
if (phone) {
address.phone = phone
}
const headers = {
...(await getAuthHeaders()),
}
return sdk.store.customer
.updateAddress(addressId, address, {}, headers)
.then(async () => {
const customerCacheTag = await getCacheTag("customers")
revalidateTag(customerCacheTag)
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
}

View File

@ -0,0 +1,68 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getAuthHeaders, getCacheOptions } from "./cookies"
export const listCartShippingMethods = async (cartId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
return sdk.client
.fetch<HttpTypes.StoreShippingOptionListResponse>(
`/store/shipping-options`,
{
method: "GET",
query: {
cart_id: cartId,
},
headers,
next,
cache: "force-cache",
}
)
.then(({ shipping_options }) => shipping_options)
.catch(() => {
return null
})
}
export const calculatePriceForShippingOption = async (
optionId: string,
cartId: string,
data?: Record<string, unknown>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("fulfillment")),
}
const body = { cart_id: cartId, data }
if (data) {
body.data = data
}
return sdk.client
.fetch<{ shipping_option: HttpTypes.StoreCartShippingOption }>(
`/store/shipping-options/${optionId}/calculate`,
{
method: "POST",
body,
headers,
next,
}
)
.then(({ shipping_option }) => shipping_option)
.catch((e) => {
return null
})
}

View File

@ -0,0 +1,74 @@
"use server"
import { sdk } from "@lib/config"
import { revalidateTag } from "next/cache"
import { cookies as nextCookies } from "next/headers"
import { getAuthHeaders, getCacheTag, getCartId } from "./cookies"
const LOCALE_COOKIE_NAME = "_medusa_locale"
/**
* Gets the current locale from cookies
*/
export const getLocale = async (): Promise<string | null> => {
try {
const cookies = await nextCookies()
return cookies.get(LOCALE_COOKIE_NAME)?.value ?? null
} catch {
return null
}
}
/**
* Sets the locale cookie
*/
export const setLocaleCookie = async (locale: string) => {
const cookies = await nextCookies()
cookies.set(LOCALE_COOKIE_NAME, locale, {
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: false, // Allow client-side access
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
/**
* Updates the locale preference via SDK and stores in cookie.
* Also updates the cart with the new locale if one exists.
*/
export const updateLocale = async (localeCode: string): Promise<string> => {
await setLocaleCookie(localeCode)
// Update cart with the new locale if a cart exists
const cartId = await getCartId()
if (cartId) {
const headers = {
...(await getAuthHeaders()),
}
await sdk.store.cart.update(cartId, { locale: localeCode }, {}, headers)
const cartCacheTag = await getCacheTag("carts")
if (cartCacheTag) {
revalidateTag(cartCacheTag)
}
}
// Revalidate relevant caches to refresh content
const productsCacheTag = await getCacheTag("products")
if (productsCacheTag) {
revalidateTag(productsCacheTag)
}
const categoriesCacheTag = await getCacheTag("categories")
if (categoriesCacheTag) {
revalidateTag(categoriesCacheTag)
}
const collectionsCacheTag = await getCacheTag("collections")
if (collectionsCacheTag) {
revalidateTag(collectionsCacheTag)
}
return localeCode
}

28
src/lib/data/locales.ts Normal file
View File

@ -0,0 +1,28 @@
"use server"
import { sdk } from "@lib/config"
import { getCacheOptions } from "./cookies"
export type Locale = {
code: string
name: string
}
/**
* Fetches available locales from the backend.
* Returns null if the endpoint returns 404 (locales not configured).
*/
export const listLocales = async (): Promise<Locale[] | null> => {
const next = {
...(await getCacheOptions("locales")),
}
return sdk.client
.fetch<{ locales: Locale[] }>(`/store/locales`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ locales }) => locales)
.catch(() => null)
}

View File

@ -0,0 +1,54 @@
"use server"
import { sdk } from "@lib/config"
export type NewsletterResult =
| { status: "success"; message: string }
| { status: "error"; message: string }
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Newsletter sign-up.
*
* Validates the address, then stores it in the Medusa backend
* (`POST /store/newsletter`, see backend/src/modules/newsletter). Subscribers
* are listed and exported from the admin under "Newsletter".
*/
export async function subscribeToNewsletter(
_prevState: NewsletterResult | null,
formData: FormData
): Promise<NewsletterResult> {
const email = String(formData.get("email") ?? "").trim()
if (!email) {
return { status: "error", message: "Please enter your email address." }
}
if (!EMAIL_PATTERN.test(email)) {
return { status: "error", message: "Please enter a valid email address." }
}
try {
const { already_subscribed } = await sdk.client.fetch<{
success: boolean
already_subscribed: boolean
}>("/store/newsletter", {
method: "POST",
body: { email, source: "footer" },
})
return {
status: "success",
message: already_subscribed
? "You are already subscribed."
: "Thank you for subscribing.",
}
} catch (error) {
console.error("[newsletter] subscription failed:", error)
return {
status: "error",
message: "Something went wrong. Please try again.",
}
}
}

View File

@ -0,0 +1,9 @@
"use server"
import { cookies as nextCookies } from "next/headers"
import { redirect } from "next/navigation"
export async function resetOnboardingState(orderId: string) {
const cookies = await nextCookies()
cookies.set("_medusa_onboarding", "false", { maxAge: -1 })
redirect(`http://localhost:7001/a/orders/${orderId}`)
}

112
src/lib/data/orders.ts Normal file
View File

@ -0,0 +1,112 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const retrieveOrder = async (id: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderResponse>(`/store/orders/${id}`, {
method: "GET",
query: {
fields:
"*payment_collections.payments,*items,*items.metadata,*items.variant,*items.product",
},
headers,
next,
cache: "force-cache",
})
.then(({ order }) => order)
.catch((err) => medusaError(err))
}
export const listOrders = async (
limit: number = 10,
offset: number = 0,
filters?: Record<string, any>
) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("orders")),
}
return sdk.client
.fetch<HttpTypes.StoreOrderListResponse>(`/store/orders`, {
method: "GET",
query: {
limit,
offset,
order: "-created_at",
fields: "*items,+items.metadata,*items.variant,*items.product",
...filters,
},
headers,
next,
cache: "force-cache",
})
.then(({ orders }) => orders)
.catch((err) => medusaError(err))
}
export const createTransferRequest = async (
state: {
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
},
formData: FormData
): Promise<{
success: boolean
error: string | null
order: HttpTypes.StoreOrder | null
}> => {
const id = formData.get("order_id") as string
if (!id) {
return { success: false, error: "Order ID is required", order: null }
}
const headers = await getAuthHeaders()
return await sdk.store.order
.requestTransfer(
id,
{},
{
fields: "id, email",
},
headers
)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const acceptTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.acceptTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}
export const declineTransferRequest = async (id: string, token: string) => {
const headers = await getAuthHeaders()
return await sdk.store.order
.declineTransfer(id, { token }, {}, headers)
.then(({ order }) => ({ success: true, error: null, order }))
.catch((err) => ({ success: false, error: err.message, order: null }))
}

35
src/lib/data/payment.ts Normal file
View File

@ -0,0 +1,35 @@
"use server"
import { sdk } from "@lib/config"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { HttpTypes } from "@medusajs/types"
export const listCartPaymentMethods = async (regionId: string) => {
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("payment_providers")),
}
return sdk.client
.fetch<HttpTypes.StorePaymentProviderListResponse>(
`/store/payment-providers`,
{
method: "GET",
query: { region_id: regionId },
headers,
next,
cache: "force-cache",
}
)
.then(({ payment_providers }) =>
payment_providers.sort((a, b) => {
return a.id > b.id ? 1 : -1
})
)
.catch(() => {
return null
})
}

135
src/lib/data/products.ts Normal file
View File

@ -0,0 +1,135 @@
"use server"
import { sdk } from "@lib/config"
import { sortProducts } from "@lib/util/sort-products"
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { getAuthHeaders, getCacheOptions } from "./cookies"
import { getRegion, retrieveRegion } from "./regions"
export const listProducts = async ({
pageParam = 1,
queryParams,
countryCode,
regionId,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
countryCode?: string
regionId?: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
}> => {
if (!countryCode && !regionId) {
throw new Error("Country code or region ID is required")
}
const limit = queryParams?.limit || 12
const _pageParam = Math.max(pageParam, 1)
const offset = _pageParam === 1 ? 0 : (_pageParam - 1) * limit
let region: HttpTypes.StoreRegion | undefined | null
if (countryCode) {
region = await getRegion(countryCode)
} else {
region = await retrieveRegion(regionId!)
}
if (!region) {
return {
response: { products: [], count: 0 },
nextPage: null,
}
}
const headers = {
...(await getAuthHeaders()),
}
const next = {
...(await getCacheOptions("products")),
}
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[]; count: number }>(
`/store/products`,
{
method: "GET",
query: {
limit,
offset,
region_id: region?.id,
fields:
"*variants.calculated_price,+variants.inventory_quantity,+variants.metadata,*variants.images,+metadata,+tags,",
...queryParams,
},
headers,
next,
}
)
.then(({ products, count }) => {
const nextPage = count > offset + limit ? pageParam + 1 : null
return {
response: {
products,
count,
},
nextPage: nextPage,
queryParams,
}
})
}
/**
* This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter.
* It will then return the paginated products based on the page and limit parameters.
*/
export const listProductsWithSort = async ({
page = 0,
queryParams,
sortBy = "created_at",
countryCode,
}: {
page?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
sortBy?: SortOptions
countryCode: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> => {
const limit = queryParams?.limit || 12
const {
response: { products, count },
} = await listProducts({
pageParam: 0,
queryParams: {
...queryParams,
limit: 100,
},
countryCode,
})
const sortedProducts = sortProducts(products, sortBy)
const pageParam = (page - 1) * limit
const nextPage = count > pageParam + limit ? pageParam + limit : null
const paginatedProducts = sortedProducts.slice(pageParam, pageParam + limit)
return {
response: {
products: paginatedProducts,
count,
},
nextPage,
queryParams,
}
}

66
src/lib/data/regions.ts Normal file
View File

@ -0,0 +1,66 @@
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
import { getCacheOptions } from "./cookies"
export const listRegions = async () => {
const next = {
...(await getCacheOptions("regions")),
}
return sdk.client
.fetch<{ regions: HttpTypes.StoreRegion[] }>(`/store/regions`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ regions }) => regions)
.catch(medusaError)
}
export const retrieveRegion = async (id: string) => {
const next = {
...(await getCacheOptions(["regions", id].join("-"))),
}
return sdk.client
.fetch<{ region: HttpTypes.StoreRegion }>(`/store/regions/${id}`, {
method: "GET",
next,
cache: "force-cache",
})
.then(({ region }) => region)
.catch(medusaError)
}
const regionMap = new Map<string, HttpTypes.StoreRegion>()
export const getRegion = async (countryCode: string) => {
try {
if (regionMap.has(countryCode)) {
return regionMap.get(countryCode)
}
const regions = await listRegions()
if (!regions) {
return null
}
regions.forEach((region) => {
region.countries?.forEach((c) => {
regionMap.set(c?.iso_2 ?? "", region)
})
})
const region = countryCode
? regionMap.get(countryCode)
: regionMap.get("us")
return region
} catch (e: any) {
return null
}
}

38
src/lib/data/variants.ts Normal file
View File

@ -0,0 +1,38 @@
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getAuthHeaders, getCacheOptions } from "./cookies"
export const retrieveVariant = async (
variant_id: string
): Promise<HttpTypes.StoreProductVariant | null> => {
const authHeaders = await getAuthHeaders()
if (!authHeaders) return null
const headers = {
...authHeaders,
}
const next = {
...(await getCacheOptions("variants")),
}
return await sdk.client
.fetch<{ variant: HttpTypes.StoreProductVariant }>(
`/store/product-variants/${variant_id}`,
{
method: "GET",
query: {
fields: "*images",
},
headers,
next,
cache: "force-cache",
}
)
.then(({ variant }) => variant)
.catch(() => null)
}

View File

@ -0,0 +1,29 @@
import { RefObject, useEffect, useState } from "react"
export const useIntersection = (
element: RefObject<HTMLDivElement | null>,
rootMargin: string
) => {
const [isVisible, setState] = useState(false)
useEffect(() => {
if (!element.current) {
return
}
const el = element.current
const observer = new IntersectionObserver(
([entry]) => {
setState(entry.isIntersecting)
},
{ rootMargin }
)
observer.observe(el)
return () => observer.unobserve(el)
}, [element, rootMargin])
return isVisible
}

View File

@ -0,0 +1,46 @@
import { useState } from "react"
export type StateType = [boolean, () => void, () => void, () => void] & {
state: boolean
open: () => void
close: () => void
toggle: () => void
}
/**
*
* @param initialState - boolean
* @returns An array like object with `state`, `open`, `close`, and `toggle` properties
* to allow both object and array destructuring
*
* ```
* const [showModal, openModal, closeModal, toggleModal] = useToggleState()
* // or
* const { state, open, close, toggle } = useToggleState()
* ```
*/
const useToggleState = (initialState = false) => {
const [state, setState] = useState<boolean>(initialState)
const close = () => {
setState(false)
}
const open = () => {
setState(true)
}
const toggle = () => {
setState((state) => !state)
}
const hookData = [state, open, close, toggle] as StateType
hookData.state = state
hookData.open = open
hookData.close = close
hookData.toggle = toggle
return hookData
}
export default useToggleState

View File

@ -0,0 +1,28 @@
import { isEqual, pick } from "lodash"
export default function compareAddresses(address1: any, address2: any) {
return isEqual(
pick(address1, [
"first_name",
"last_name",
"address_1",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
]),
pick(address2, [
"first_name",
"last_name",
"address_1",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
])
)
}

3
src/lib/util/env.ts Normal file
View File

@ -0,0 +1,3 @@
export const getBaseURL = () => {
return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
}

View File

@ -0,0 +1,8 @@
import { getLocale } from "@lib/data/locale-actions"
export async function getLocaleHeader() {
const locale = await getLocale()
return {
"x-medusa-locale": locale,
} as const
}

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest"
import { getPercentageDiff } from "./get-percentage-diff"
describe("getPercentageDiff", () => {
it("returns the percentage decrease between original and calculated price", () => {
expect(getPercentageDiff(100, 80)).toBe("20")
})
it("returns 0 when there is no price difference", () => {
expect(getPercentageDiff(100, 100)).toBe("0")
})
})

View File

@ -0,0 +1,6 @@
export const getPercentageDiff = (original: number, calculated: number) => {
const diff = original - calculated
const decrease = (diff / original) * 100
return decrease.toFixed()
}

View File

@ -0,0 +1,81 @@
import { HttpTypes } from "@medusajs/types"
import { getPercentageDiff } from "./get-percentage-diff"
import { convertToLocale } from "./money"
export const getPricesForVariant = (variant: any) => {
if (!variant?.calculated_price?.calculated_amount) {
return null
}
return {
calculated_price_number: variant.calculated_price.calculated_amount,
calculated_price: convertToLocale({
amount: variant.calculated_price.calculated_amount,
currency_code: variant.calculated_price.currency_code,
}),
original_price_number: variant.calculated_price.original_amount,
original_price: convertToLocale({
amount: variant.calculated_price.original_amount,
currency_code: variant.calculated_price.currency_code,
}),
currency_code: variant.calculated_price.currency_code,
price_type: variant.calculated_price.calculated_price.price_list_type,
percentage_diff: getPercentageDiff(
variant.calculated_price.original_amount,
variant.calculated_price.calculated_amount
),
}
}
export function getProductPrice({
product,
variantId,
}: {
product: HttpTypes.StoreProduct
variantId?: string
}) {
if (!product || !product.id) {
throw new Error("No product provided")
}
const cheapestPrice = () => {
if (!product || !product.variants?.length) {
return null
}
const cheapestVariant: any = product.variants
// Bespoke variants are priced as a deposit, they must not become the
// product's "from" price.
.filter((v: any) => !!v.calculated_price && !v.metadata?.is_bespoke)
.sort((a: any, b: any) => {
return (
a.calculated_price.calculated_amount -
b.calculated_price.calculated_amount
)
})[0]
return getPricesForVariant(cheapestVariant)
}
const variantPrice = () => {
if (!product || !variantId) {
return null
}
const variant: any = product.variants?.find(
(v) => v.id === variantId || v.sku === variantId
)
if (!variant) {
return null
}
return getPricesForVariant(variant)
}
return {
product,
cheapestPrice: cheapestPrice(),
variantPrice: variantPrice(),
}
}

11
src/lib/util/isEmpty.ts Normal file
View File

@ -0,0 +1,11 @@
export const isObject = (input: any) => input instanceof Object
export const isArray = (input: any) => Array.isArray(input)
export const isEmpty = (input: any) => {
return (
input === null ||
input === undefined ||
(isObject(input) && Object.keys(input).length === 0) ||
(isArray(input) && (input as any[]).length === 0) ||
(typeof input === "string" && input.trim().length === 0)
)
}

View File

@ -0,0 +1,22 @@
export default function medusaError(error: any): never {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const u = new URL(error.config.url, error.config.baseURL)
console.error("Resource:", u.toString())
console.error("Response data:", error.response.data)
console.error("Status code:", error.response.status)
console.error("Headers:", error.response.headers)
// Extracting the error message from the response data
const message = error.response.data.message || error.response.data
throw new Error(message.charAt(0).toUpperCase() + message.slice(1) + ".")
} else if (error.request) {
// The request was made but no response was received
throw new Error("No response received: " + error.request)
} else {
// Something happened in setting up the request that triggered an Error
throw new Error("Error setting up the request: " + error.message)
}
}

26
src/lib/util/money.ts Normal file
View File

@ -0,0 +1,26 @@
import { isEmpty } from "./isEmpty"
type ConvertToLocaleParams = {
amount: number
currency_code: string
minimumFractionDigits?: number
maximumFractionDigits?: number
locale?: string
}
export const convertToLocale = ({
amount,
currency_code,
minimumFractionDigits,
maximumFractionDigits,
locale = "en-US",
}: ConvertToLocaleParams) => {
return currency_code && !isEmpty(currency_code)
? new Intl.NumberFormat(locale, {
style: "currency",
currency: currency_code,
minimumFractionDigits,
maximumFractionDigits,
}).format(amount)
: amount.toString()
}

5
src/lib/util/product.ts Normal file
View File

@ -0,0 +1,5 @@
import { HttpTypes } from "@medusajs/types";
export const isSimpleProduct = (product: HttpTypes.StoreProduct): boolean => {
return product.options?.length === 1 && product.options[0].values?.length === 1;
}

5
src/lib/util/repeat.ts Normal file
View File

@ -0,0 +1,5 @@
const repeat = (times: number) => {
return Array.from(Array(times).keys())
}
export default repeat

View File

@ -0,0 +1,50 @@
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
interface MinPricedProduct extends HttpTypes.StoreProduct {
_minPrice?: number
}
/**
* Helper function to sort products by price until the store API supports sorting by price
* @param products
* @param sortBy
* @returns products sorted by price
*/
export function sortProducts(
products: HttpTypes.StoreProduct[],
sortBy: SortOptions
): HttpTypes.StoreProduct[] {
let sortedProducts = products as MinPricedProduct[]
if (["price_asc", "price_desc"].includes(sortBy)) {
// Precompute the minimum price for each product
sortedProducts.forEach((product) => {
if (product.variants && product.variants.length > 0) {
product._minPrice = Math.min(
...product.variants.map(
(variant) => variant?.calculated_price?.calculated_amount || 0
)
)
} else {
product._minPrice = Infinity
}
})
// Sort products based on the precomputed minimum prices
sortedProducts.sort((a, b) => {
const diff = a._minPrice! - b._minPrice!
return sortBy === "price_asc" ? diff : -diff
})
}
if (sortBy === "created_at") {
sortedProducts.sort((a, b) => {
return (
new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
)
})
}
return sortedProducts
}

165
src/middleware.ts Normal file
View File

@ -0,0 +1,165 @@
import { HttpTypes } from "@medusajs/types"
import { NextRequest, NextResponse } from "next/server"
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL
const PUBLISHABLE_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
const DEFAULT_REGION = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
const regionMapCache = {
regionMap: new Map<string, HttpTypes.StoreRegion>(),
regionMapUpdated: Date.now(),
}
async function getRegionMap(cacheId: string) {
const { regionMap, regionMapUpdated } = regionMapCache
if (!BACKEND_URL) {
throw new Error(
"Middleware.ts: Error fetching regions. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
)
}
if (
!regionMap.keys().next().value ||
regionMapUpdated < Date.now() - 3600 * 1000
) {
// Fetch regions from Medusa. We can't use the JS client here because middleware is running on Edge and the client needs a Node environment.
const { regions } = await fetch(`${BACKEND_URL}/store/regions`, {
headers: {
"x-publishable-api-key": PUBLISHABLE_API_KEY!,
},
next: {
revalidate: 3600,
tags: [`regions-${cacheId}`],
},
cache: "force-cache",
}).then(async (response) => {
const json = await response.json()
if (!response.ok) {
throw new Error(json.message)
}
return json
})
if (!regions?.length) {
throw new Error(
"No regions found. Please set up regions in your Medusa Admin."
)
}
// Create a map of country codes to regions.
regions.forEach((region: HttpTypes.StoreRegion) => {
region.countries?.forEach((c) => {
regionMapCache.regionMap.set(c.iso_2 ?? "", region)
})
})
regionMapCache.regionMapUpdated = Date.now()
}
return regionMapCache.regionMap
}
/**
* Fetches regions from Medusa and sets the region cookie.
* @param request
* @param response
*/
async function getCountryCode(
request: NextRequest,
regionMap: Map<string, HttpTypes.StoreRegion | number>
) {
try {
let countryCode
const vercelCountryCode = request.headers
.get("x-vercel-ip-country")
?.toLowerCase()
const urlCountryCode = request.nextUrl.pathname.split("/")[1]?.toLowerCase()
if (urlCountryCode && regionMap.has(urlCountryCode)) {
countryCode = urlCountryCode
} else if (vercelCountryCode && regionMap.has(vercelCountryCode)) {
countryCode = vercelCountryCode
} else if (regionMap.has(DEFAULT_REGION)) {
countryCode = DEFAULT_REGION
} else if (regionMap.keys().next().value) {
countryCode = regionMap.keys().next().value
}
return countryCode
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error(
"Middleware.ts: Error getting the country code. Did you set up regions in your Medusa Admin and define a MEDUSA_BACKEND_URL environment variable? Note that the variable is no longer named NEXT_PUBLIC_MEDUSA_BACKEND_URL."
)
}
}
}
/**
* Middleware to handle region selection and onboarding status.
*/
export async function middleware(request: NextRequest) {
let redirectUrl = request.nextUrl.href
let response = NextResponse.redirect(redirectUrl, 307)
let cacheIdCookie = request.cookies.get("_medusa_cache_id")
let cacheId = cacheIdCookie?.value || crypto.randomUUID()
const regionMap = await getRegionMap(cacheId)
const countryCode = regionMap && (await getCountryCode(request, regionMap))
const urlHasCountryCode =
countryCode && request.nextUrl.pathname.split("/")[1].includes(countryCode)
// if one of the country codes is in the url and the cache id is set, return next
if (urlHasCountryCode && cacheIdCookie) {
return NextResponse.next()
}
// if one of the country codes is in the url and the cache id is not set, set the cache id and redirect
if (urlHasCountryCode && !cacheIdCookie) {
response.cookies.set("_medusa_cache_id", cacheId, {
maxAge: 60 * 60 * 24,
})
return response
}
// check if the url is a static asset
if (request.nextUrl.pathname.includes(".")) {
return NextResponse.next()
}
const redirectPath =
request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname
const queryString = request.nextUrl.search ? request.nextUrl.search : ""
// If no country code is set, we redirect to the relevant region.
if (!urlHasCountryCode && countryCode) {
redirectUrl = `${request.nextUrl.origin}/${countryCode}${redirectPath}${queryString}`
response = NextResponse.redirect(`${redirectUrl}`, 307)
} else if (!urlHasCountryCode && !countryCode) {
// Handle case where no valid country code exists (empty regions)
return new NextResponse(
"No valid regions configured. Please set up regions with countries in your Medusa Admin.",
{ status: 500 }
)
}
return response
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|images|assets|png|svg|jpg|jpeg|gif|webp).*)",
],
}

View File

@ -0,0 +1,139 @@
import { Disclosure } from "@headlessui/react"
import { Badge, Button, clx } from "@medusajs/ui"
import { useEffect } from "react"
import useToggleState from "@lib/hooks/use-toggle-state"
import { useFormStatus } from "react-dom"
type AccountInfoProps = {
label: string
currentInfo: string | React.ReactNode
isSuccess?: boolean
isError?: boolean
errorMessage?: string
clearState: () => void
children?: React.ReactNode
'data-testid'?: string
}
const AccountInfo = ({
label,
currentInfo,
isSuccess,
isError,
clearState,
errorMessage = "An error occurred, please try again",
children,
'data-testid': dataTestid
}: AccountInfoProps) => {
const { state, close, toggle } = useToggleState()
const { pending } = useFormStatus()
const handleToggle = () => {
clearState()
setTimeout(() => toggle(), 100)
}
useEffect(() => {
if (isSuccess) {
close()
}
}, [isSuccess, close])
return (
<div className="text-small-regular" data-testid={dataTestid}>
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="uppercase text-ui-fg-base">{label}</span>
<div className="flex items-center flex-1 basis-0 justify-end gap-x-4">
{typeof currentInfo === "string" ? (
<span className="font-semibold" data-testid="current-info">{currentInfo}</span>
) : (
currentInfo
)}
</div>
</div>
<div>
<Button
variant="secondary"
className="w-[100px] min-h-[25px] py-1"
onClick={handleToggle}
type={state ? "reset" : "button"}
data-testid="edit-button"
data-active={state}
>
{state ? "Cancel" : "Edit"}
</Button>
</div>
</div>
{/* Success state */}
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
{
"max-h-[1000px] opacity-100": isSuccess,
"max-h-0 opacity-0": !isSuccess,
}
)}
data-testid="success-message"
>
<Badge className="p-2 my-4" color="green">
<span>{label} updated succesfully</span>
</Badge>
</Disclosure.Panel>
</Disclosure>
{/* Error state */}
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-hidden",
{
"max-h-[1000px] opacity-100": isError,
"max-h-0 opacity-0": !isError,
}
)}
data-testid="error-message"
>
<Badge className="p-2 my-4" color="red">
<span>{errorMessage}</span>
</Badge>
</Disclosure.Panel>
</Disclosure>
<Disclosure>
<Disclosure.Panel
static
className={clx(
"transition-[max-height,opacity] duration-300 ease-in-out overflow-visible",
{
"max-h-[1000px] opacity-100": state,
"max-h-0 opacity-0": !state,
}
)}
>
<div className="flex flex-col gap-y-2 py-4">
<div>{children}</div>
<div className="flex items-center justify-end mt-2">
<Button
isLoading={pending}
className="w-full small:max-w-[140px]"
type="submit"
data-testid="save-button"
>
Save changes
</Button>
</div>
</div>
</Disclosure.Panel>
</Disclosure>
</div>
)
}
export default AccountInfo

Some files were not shown because too many files have changed in this diff Show More