Initial Source code
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
!.env.template
|
||||||
|
coverage
|
||||||
|
mock-backend
|
||||||
|
docs
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
35
.env.template
Normal 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
@ -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
@ -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
@ -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
7
.yarnrc.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
compressionLevel: mixed
|
||||||
|
|
||||||
|
enableGlobalCache: false
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||||
43
Dockerfile
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
76
package.json
Normal 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
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
public/apparel_collection.png
Normal file
|
After Width: | Height: | Size: 689 KiB |
BIN
public/bags_collection.png
Normal file
|
After Width: | Height: | Size: 665 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icon.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/logo.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
30
src/app/[countryCode]/(checkout)/checkout/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/app/[countryCode]/(checkout)/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/app/[countryCode]/(checkout)/not-found.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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} />
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/app/[countryCode]/(main)/account/@dashboard/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
@ -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" />
|
||||||
|
}
|
||||||
|
;``
|
||||||
13
src/app/[countryCode]/(main)/account/@login/page.tsx
Normal 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 />
|
||||||
|
}
|
||||||
20
src/app/[countryCode]/(main)/account/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/app/[countryCode]/(main)/account/loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/app/[countryCode]/(main)/cart/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SkeletonCartPage />
|
||||||
|
}
|
||||||
21
src/app/[countryCode]/(main)/cart/not-found.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/app/[countryCode]/(main)/cart/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
120
src/app/[countryCode]/(main)/categories/[...category]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/app/[countryCode]/(main)/collections/[handle]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/app/[countryCode]/(main)/collections/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
src/app/[countryCode]/(main)/info/[handle]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/app/[countryCode]/(main)/layout.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/app/[countryCode]/(main)/not-found.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SkeletonOrderConfirmed />
|
||||||
|
}
|
||||||
23
src/app/[countryCode]/(main)/order/[id]/confirmed/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
src/app/[countryCode]/(main)/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/app/[countryCode]/(main)/products/[handle]/page.test.ts
Normal 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",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
169
src/app/[countryCode]/(main)/products/[handle]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/app/[countryCode]/(main)/store/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/app/[countryCode]/(main)/wishlist/page.tsx
Normal 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
|
After Width: | Height: | Size: 33 KiB |
49
src/app/layout.tsx
Normal 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
@ -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
|
After Width: | Height: | Size: 229 KiB |
19
src/app/robots.ts
Normal 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
@ -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
|
After Width: | Height: | Size: 229 KiB |
30
src/lib/brand.ts
Normal 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
@ -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
@ -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",
|
||||||
|
]
|
||||||
264
src/lib/content/info-pages.ts
Normal 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)
|
||||||
34
src/lib/context/modal-context.tsx
Normal 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
|
||||||
|
}
|
||||||
94
src/lib/context/wishlist-context.tsx
Normal 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
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
49
src/lib/data/categories.ts
Normal 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])
|
||||||
|
}
|
||||||
59
src/lib/data/collections.ts
Normal 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
@ -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
@ -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() }
|
||||||
|
})
|
||||||
|
}
|
||||||
68
src/lib/data/fulfillment.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
74
src/lib/data/locale-actions.ts
Normal 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
@ -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)
|
||||||
|
}
|
||||||
54
src/lib/data/newsletter.ts
Normal 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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/lib/data/onboarding.ts
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||||
|
}
|
||||||
29
src/lib/hooks/use-in-view.tsx
Normal 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
|
||||||
|
}
|
||||||
46
src/lib/hooks/use-toggle-state.tsx
Normal 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
|
||||||
28
src/lib/util/compare-addresses.ts
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
export const getBaseURL = () => {
|
||||||
|
return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
|
||||||
|
}
|
||||||
8
src/lib/util/get-locale-header.ts
Normal 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
|
||||||
|
}
|
||||||
12
src/lib/util/get-percentage-diff.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
6
src/lib/util/get-percentage-diff.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export const getPercentageDiff = (original: number, calculated: number) => {
|
||||||
|
const diff = original - calculated
|
||||||
|
const decrease = (diff / original) * 100
|
||||||
|
|
||||||
|
return decrease.toFixed()
|
||||||
|
}
|
||||||
81
src/lib/util/get-product-price.ts
Normal 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
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/lib/util/medusa-error.ts
Normal 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
@ -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
@ -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
@ -0,0 +1,5 @@
|
|||||||
|
const repeat = (times: number) => {
|
||||||
|
return Array.from(Array(times).keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
export default repeat
|
||||||
50
src/lib/util/sort-products.ts
Normal 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
@ -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).*)",
|
||||||
|
],
|
||||||
|
}
|
||||||
139
src/modules/account/components/account-info/index.tsx
Normal 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
|
||||||