diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..61eb39e5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# sklepikFront — instrukcja dla agentów kodowania + +To repozytorium to **storefront projektu Kakałowy Sklepik**: Next.js 16 + React 19, doświadczenie klienta, branding, UX, SEO, deploy na Vercel. Rozmawia ze Store API backendu przez `@spree/sdk`. + +Backend, admin i API żyją w osobnym repo **`pawelekbyra/sklepik`** — tam też jest **kanon całego systemu**. Nie duplikujemy go tutaj: + +- `sklepik/docs/kierunek-projektu.md` — cel projektu, podział repo, **hierarchia decyzji**. +- `sklepik/docs/architektura.md` — mapa systemu i hostingu. +- `sklepik/docs/stan-projektu.md` — bieżący stan i znane problemy. +- `sklepik/docs/roadmap.md` — backlog i priorytety. + +**Konwencje techniczne tego repo i protokół dokumentacji: [`CLAUDE.md`](CLAUDE.md).** Tematy frontowe: [`docs/README.md`](docs/README.md). + +Żelazne minimum: + +- Frontend nie zawiera logiki commerce — produkty, ceny, koszyk, zamówienia to zawsze Store API. Jeśli brakuje pola/endpointu, opisz wymaganie względem repo `sklepik`, nie hardcoduj. +- Nie zmieniaj checkoutu/koszyka przy okazji zmian wizualnych; eksperymenty (quizy, VOD, AI) izoluj od krytycznej ścieżki zakupowej. +- Nie commituj sekretów. Zmienne środowiskowe: [`docs/deployment-vercel.md`](docs/deployment-vercel.md). +- Świadome skróty zapisuj w [`docs/technical-debt.md`](docs/technical-debt.md). +- **Po każdym zadaniu zaktualizuj dotknięte dokumenty** (tu i — jeśli zmiana dotyczy całego systemu — `sklepik/docs/stan-projektu.md`), tak żeby dokumentacja odzwierciedlała rzeczywisty stan. diff --git a/CLAUDE.md b/CLAUDE.md index d5834897..325c3e74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,30 @@ -# Claude Code Rules for Next.js Spree Storefront +# Kakałowy Sklepik — storefront: zasady dla agentów -## Project Overview +## Kontekst projektu (przeczytaj najpierw) -This is a headless e-commerce storefront built with Next.js 16 and React 19, using the Spree Commerce API v3 via `@spree/sdk`. +To repozytorium to **storefront projektu Kakałowy Sklepik** (sklep z produktami kakao) — headless frontend Next.js 16 + React 19, konsumujący Store API v3 backendu przez `@spree/sdk`. Backend, API i panel admina żyją w osobnym repo `pawelekbyra/sklepik` — tam jest też **kanon całego systemu**: + +- `sklepik/docs/kierunek-projektu.md` — cel, podział repo, hierarchia decyzji. +- `sklepik/docs/stan-projektu.md` — bieżący stan i znane problemy. +- `sklepik/docs/roadmap.md` — backlog i priorytety. +- Lokalna dokumentacja frontu: [`docs/README.md`](docs/README.md) (kierunek marki, deploy Vercel, dług techniczny). + +Zasada nadrzędna: frontend nie zawiera logiki commerce. Produkty, ceny, koszyk, zamówienia — zawsze przez Store API. Brakuje pola/endpointu → wymaganie względem repo `sklepik`, nie hardcode. + +## Protokół dokumentacji (obowiązkowy) + +Po każdym zakończonym zadaniu, w tym samym PR/commicie: + +1. Zaktualizuj dotknięte dokumenty w `docs/` (deploy, kierunek frontu) tak, żeby opisywały rzeczywisty stan. +2. Świadomy skrót → wpis w [`docs/technical-debt.md`](docs/technical-debt.md); spłacony dług → zmień status na `zamknięte`. +3. Zmiana istotna dla całego systemu (API, architektura, nowa usługa) → zaktualizuj też `sklepik/docs/stan-projektu.md` w repo backendu albo jawnie to zgłoś. +4. Nie twórz nowych plików-notatek — aktualizuj istniejące. Historia jest w gicie. + +--- + +## Tech Overview + +Headless e-commerce storefront: Next.js 16 + React 19 + Spree Commerce API v3 via `@spree/sdk`. ## Tech Stack @@ -17,16 +39,21 @@ This is a headless e-commerce storefront built with Next.js 16 and React 19, usi ``` src/ ├── app/ # Next.js App Router -│ └── [country]/[locale]/ # Internationalized routes -│ ├── (checkout)/ # Checkout route group (minimal layout) -│ └── (storefront)/ # Storefront route group (full layout) +│ ├── [locale]/ # pl = default, no URL prefix; other locales get /{locale} +│ │ ├── (checkout)/ # Checkout route group (minimal layout) +│ │ └── (storefront)/ # Storefront route group (full layout) +│ └── api/webhooks/ # Webhooks from the backend (transactional emails) ├── components/ # Reusable UI components ├── contexts/ # React Context providers ├── lib/ -│ └── data/ # Server Actions for data fetching +│ ├── data/ # Server Actions for data fetching +│ ├── spree/ # SDK client, middleware, config +│ └── store.ts # Store name + default country/locale (env, defaults pl) └── types/ # TypeScript type definitions ``` +Uwaga: segment `[country]` został usunięty z URL-i (sklep jednorynkowy — kraj do rozwiązania rynku/waluty jest stałym defaultem server-side). Przykłady niżej używające starych ścieżek traktuj jako wzorce kodu, nie jako mapę routingu. + ## React 19 Best Practices ### Avoid Unnecessary useEffect diff --git a/README.md b/README.md index 388fd874..258cf14c 100644 --- a/README.md +++ b/README.md @@ -1,475 +1,58 @@ -[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Next.js 16](https://img.shields.io/badge/Next.js-16-black)](https://nextjs.org) -[![React 19](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)](https://react.dev) +# Kakałowy Sklepik — storefront -# Spree Storefront +Sklep internetowy dla klientów projektu **Kakałowy Sklepik** (produkty kakao). To repozytorium jest **frontendem** systemu: Next.js 16 + React 19 + Tailwind, rozmawia ze Store API backendu przez `@spree/sdk`. Silnik commerce (backend, API, panel admina) żyje w drugim repozytorium — [`pawelekbyra/sklepik`](https://github.com/pawelekbyra/sklepik). -A production-ready, headless ecommerce storefront for [Spree Commerce](https://spreecommerce.org), built with Next.js 16, React 19, and the [Spree REST API](https://spreecommerce.org/docs/api-reference). Open source (MIT) and free to fork and customize. - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/spree/storefront) - -[Live Demo](https://demo.spreecommerce.org) | [Quickstart Docs](https://spreecommerce.org/docs/developer/storefront/nextjs/quickstart) | [TypeScript SDK](https://www.npmjs.com/package/@spree/sdk) - -## Why This Storefront - -**TypeScript SDK.** [@spree/sdk](https://www.npmjs.com/package/@spree/sdk) is an official typed client for every Store API endpoint (OpenAPI 3.0 documented). Autocomplete and type safety in your editor, no codegen step to maintain. - -**Multi-region out of the box.** Country, currency, and language switching via URL segments (`/us/en/`, `/de/de/`, `/uk/en/`), powered by Spree [Markets](https://spreecommerce.org/docs/developer/core-concepts/markets). Distinct selling regions bundling geography, currency, and locale in a single store. - -**One-page checkout.** Guest and authenticated users, multi-shipment, coupon codes, gift cards, and store credit. Stripe, PayPal, and Adyen via Payment Sessions. Card data never touches your server. [Swap providers](https://spreecommerce.org/docs/developer/core-concepts/payments) easily. - -**Transactional emails.** Order confirmation, shipping notification, password reset. Built with react-email, sent via Resend, triggered by Spree webhooks. - -**MIT licensed.** Open source and free to use. - -## Performance - -The live demo at [demo.spreecommerce.org](https://demo.spreecommerce.org) scores 98/100 on desktop and 88/100 on mobile for Performance on Google's Lighthouse audit, with five language versions served from the same deployment. - -| Lighthouse metric | Mobile | Desktop | -|-------------------|--------|---------| -| Performance | 88 | 98 | -| Accessibility | 100 | 100 | -| Best Practices | 100 | 100 | -| SEO | 100 | 100 | - -No external performance plugins. No edge-side rendering hacks. The architecture is Next.js 16 App Router with React 19 Server Components, server-side data fetching via `@spree/sdk`, and Tailwind CSS 4. - -[Run the audit yourself on PageSpeed Insights](https://pagespeed.web.dev/analysis?url=https%3A%2F%2Fdemo.spreecommerce.org) - -## Features - -| Feature | Details | -|---------|---------| -| **Product Catalog** | Browse, search, filter products by categories, and use faceted navigation. Search and facet filtering powered by [Meilisearch](https://spreecommerce.org/docs/integrations/search/meilisearch) | -| **Product Details** | View product information with variant selection and media | -| **Shopping Cart** | Add, update, and remove items with server-side state | -| **One-page Checkout** | Guest visitors and signed-in users supported, multi-shipments supported natively, Coupon Codes, Gift Cards, Store Credit | -| **Stripe payments** | native Stripe payment support with Stripe SDKs, PCI-Compliant, 3DS-Secure, use Credit Cards, Apple Pay, Google Pay, Klarna, Affirm, SEPA payments, and all other payment methods provided by [Spree Stripe integration](https://github.com/spree/spree_stripe) | -| **Customer Account** | Full account management: Profile management, Order history with detailed order view, Address book (create, edit, delete), Gift Cards and Store Credit, Saved payment methods | -| **Multi-Region Support** | Country, currency, and language switching via URL segments, powered by [Spree Markets](https://spreecommerce.org/docs/developer/core-concepts/markets) | -| **Responsive Design** | Mobile-first Tailwind CSS styling | -| **Google Tag Manager** and **Google Analytics 4 Ecommerce events** | tracking supported natively | -| **Store Policies** | Policy pages fetched from Spree API, with consent checkboxes on registration and guest checkout | -| **SEO-ready** | meta tags, JSON-LD, OpenGraph — all built in | -| **Error Tracking** | Sentry integration for both server-side and client-side error monitoring with source maps | - -## Technology - -| Technology | Role | -|------------|------| -| **Next.js 16** | App Router, Server Actions, Turbopack | -| **React 19** | Latest React with improved Server Components | -| **Tailwind CSS 4** | Utility-first styling | -| **TypeScript** | Full type safety | -| **Sentry** | Error tracking and performance monitoring with source maps | -| [@spree/sdk](https://spreecommerce.org/docs/developer/sdk/quickstart) | Official Spree Commerce SDK | - -## Architecture - -This starter follows a **server-first pattern**: - -1. **Server-First Architecture** - All API calls happen server-side using Next.js Server Actions -2. **httpOnly Cookies** - Auth tokens and cart tokens are stored securely -3. **No Client-Side API Calls** - The Spree API key is never exposed to the browser -4. **Cache Revalidation** - Uses Next.js cache tags for efficient updates - -``` -Browser → Server Action → @spree/sdk → Spree API - (with httpOnly cookies via src/lib/spree helpers) -``` - -## Getting Started - -### Prerequisites - -- Node.js 20+ (required for Next.js 16) -- A running Spree Commerce 5.4+ - -### Installation - -1. Install dependencies: - -```bash -npm install -``` - -2. Copy the environment file and configure: - -```bash -cp .env.local.example .env.local -``` - -3. Update `.env.local` with your Spree API credentials: - -```env -SPREE_API_URL=http://localhost:3000 -SPREE_PUBLISHABLE_KEY=your_publishable_api_key_here -``` - -> Note: These are server-side only variables (no `NEXT_PUBLIC_` prefix needed). - -#### Optional variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `NEXT_PUBLIC_SITE_URL` | Public site URL for sitemap and robots.txt generation (e.g. `https://mystore.com`) | _(required for sitemap)_ | -| `NEXT_PUBLIC_DEFAULT_COUNTRY` | Default country ISO code, used for initial redirects and as build-time fallback for sitemap generation | `us` | -| `NEXT_PUBLIC_DEFAULT_LOCALE` | Default locale code, used for initial redirects and as build-time fallback for sitemap generation | `en` | -| `GTM_ID` | Google Tag Manager container ID (e.g. `GTM-XXXXXXX`) | _(disabled)_ | -| `SENTRY_DSN` | Sentry DSN for error tracking (e.g. `https://key@o0.ingest.sentry.io/0`) | _(disabled)_ | -| `SENTRY_ORG` | Sentry organization slug (for source map uploads) | _(none)_ | -| `SENTRY_PROJECT` | Sentry project slug (for source map uploads) | _(none)_ | -| `SENTRY_AUTH_TOKEN` | Sentry auth token (for source map uploads in CI) | _(none)_ | -| `SPREE_WEBHOOK_SECRET` | Webhook endpoint secret key (for transactional emails) | _(disabled)_ | -| `RESEND_API_KEY` | [Resend](https://resend.com) API key for sending emails in production | _(dev: writes to disk)_ | -| `EMAIL_FROM` | "From" address for transactional emails (e.g. `Store `) | `orders@example.com` | -| `SENTRY_SEND_DEFAULT_PII` | Send PII (IP addresses, cookies, user data) to Sentry server-side | `false` | -| `NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII` | Send PII to Sentry client-side | `false` | - -> **Privacy note:** PII collection is disabled by default. Only set `SENTRY_SEND_DEFAULT_PII` / `NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII` to `true` if you have appropriate user consent or a privacy policy covering this data. - -### Development - -```bash -npm run dev -``` - -Open [http://localhost:3001](http://localhost:3001) in your browser. - -### HTTPS Development (Apple Pay / Google Pay) - -Apple Pay and Google Pay require HTTPS **and a publicly-reachable URL** — Stripe verifies the payment method domain from the internet, so `localhost` and locally-trusted certificates (e.g. `mkcert` + `lvh.me`) won't pass domain verification. The simplest way to expose your local storefront with a valid public HTTPS URL is [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/): - -1. Install `cloudflared`: - -```bash -brew install cloudflared -``` - -2. Start the dev server normally (HTTP on port 3001): - -```bash -npm run dev +```text +pawelekbyra/sklepik ← silnik: backend Rails, Admin API + Store API, panel admina +pawelekbyra/sklepikFront ← TO REPO: storefront Next.js — branding, UX, SEO, Vercel ``` -3. In a second terminal, expose it through a quick tunnel: +Zasada podziału: cała logika commerce (produkty, ceny, koszyk, zamówienia, płatności) należy do backendu. Ten frontend renderuje doświadczenie klienta i niczego nie udaje hardcodem. -```bash -cloudflared tunnel --url http://localhost:3001 -``` +## Zacznij tutaj -The output will contain a URL like `https://.trycloudflare.com`. +1. **Kanon systemu** (wizja, hierarchia decyzji, architektura, stan, roadmapa): `sklepik/docs/` — [kierunek-projektu](https://github.com/pawelekbyra/sklepik/blob/main/docs/kierunek-projektu.md) · [architektura](https://github.com/pawelekbyra/sklepik/blob/main/docs/architektura.md) · [stan projektu](https://github.com/pawelekbyra/sklepik/blob/main/docs/stan-projektu.md) · [roadmapa](https://github.com/pawelekbyra/sklepik/blob/main/docs/roadmap.md) +2. **Kierunek frontu i marki:** [`docs/kierunek-frontu.md`](docs/kierunek-frontu.md) +3. **Zasady dla agentów kodowania:** [`AGENTS.md`](AGENTS.md) i [`CLAUDE.md`](CLAUDE.md) +4. **Deploy i zmienne środowiskowe:** [`docs/deployment-vercel.md`](docs/deployment-vercel.md) -4. Register that URL in your [Stripe Payment method domains](https://dashboard.stripe.com/settings/payment_methods/domains). - -5. Open the tunnel URL in your browser and test the Express Checkout buttons in the cart. - -> **`allowedDevOrigins`:** `next.config.ts` already allows `*.trycloudflare.com`, so quick tunnels work out of the box. Every time you restart `cloudflared tunnel --url ...` you get a new random subdomain — if you need a stable URL (to avoid re-registering in Stripe on each run), set up a [named tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/#using-named-tunnels) on your own domain. - -> **Spree backend must also be publicly reachable.** The storefront's server-side fetches go to `SPREE_API_URL`, but image URLs and a few other backend-served paths (e.g. the Apple Pay domain-verification file under `/.well-known/apple-developer-merchantid-domain-association`) are fetched by the browser directly and must resolve from the public internet. Point `SPREE_API_URL` at a hosted Spree (e.g. `*.spree.sh`, `*.vendo.dev`, your own staging) or expose your local Spree with another `cloudflared tunnel --url http://localhost:3000`. When tunneling a local Rails app, allow the tunnel host, e.g. `RAILS_DEVELOPMENT_HOSTS=.trycloudflare.com` in the backend's `.env`. - -### Production Build - -```bash -npm run build -npm start -``` - -### Testing - -Unit and integration tests run through Vitest: - -```bash -npm test # one-shot -npm run test:watch # watch mode -``` - -End-to-end tests run through Playwright against a real Spree backend booted in Docker. The compose file at `e2e-backend/docker-compose.yml` ships Postgres + Redis + the official `ghcr.io/spree/spree:5.4.3.1` image — no `create-spree-app` setup required. Seeding and API-key creation go through the official [`@spree/cli`](https://spreecommerce.org/docs/developer/cli/quickstart) (`spree seed`, `spree sample-data`, `spree api-key create`), installed as a dev dependency. - -```bash -# 1. Export a Stripe test-mode key pair from your own Stripe sandbox. -# Both keys must belong to the same account — Stripe no longer -# publishes a working sample secret key, and a mismatched pair makes -# the checkout payment step fail. -export STRIPE_PUBLISHABLE_KEY=pk_test_… -export STRIPE_SECRET_KEY=sk_test_… - -# 2. Boot Spree + Postgres + Redis, seed sample data, register a Stripe -# payment gateway, mint a publishable key, and write .env.e2e. -npm run e2e:up - -# 3. Run the suite. Playwright boots `next dev` against .env.e2e. -npm run test:e2e - -# Optional: interactive UI mode. -npm run test:e2e:ui +## Deployment -# Tear everything down. -npm run e2e:down -``` +Vercel, projekt `sklepik_front` → `sklepikkk.vercel.app` (deploy automatyczny z `main`). Backend produkcyjny: `kakaowy-sklepik.onrender.com` (Render). -The checkout test pays with card `4242 4242 4242 4242` through Stripe's [test mode](https://docs.stripe.com/keys). PaymentIntents land in whichever Stripe test account owns the keys you exported. In CI, set `STRIPE_SECRET_KEY` as a repository secret and `STRIPE_PUBLISHABLE_KEY` as a repository variable (Settings → Secrets and variables → Actions). The E2E job skips itself on fork PRs, where GitHub never exposes repository secrets. +## Stack i struktura -## Project Structure +Next.js 16 (App Router, Server Components), React 19, Tailwind CSS 4, TypeScript, `@spree/sdk`, Biome, Vitest + Playwright. -``` +```text src/ ├── app/ -│ └── [country]/[locale]/ # Localized routes -│ ├── account/ # Customer account pages -│ │ ├── addresses/ # Address management -│ │ ├── credit-cards/ # Saved payment methods -│ │ ├── orders/ # Order history -│ │ │ └── [id]/ # Order details -│ │ ├── register/ # Registration -│ │ └── profile/ # Profile settings -│ ├── cart/ # Shopping cart -│ ├── policies/ # Store policy pages -│ ├── products/ # Product listing -│ │ └── [slug]/ # Product details -│ ├── t/[...permalink]/ # Taxon/category pages -│ └── taxonomies/ # Category overview +│ ├── [locale]/ # pl bez prefiksu w URL; inne języki z prefiksem /{locale} +│ │ ├── (storefront)/ # pełny layout: katalog, produkt, koszyk, konto +│ │ └── (checkout)/ # minimalny layout checkoutu +│ └── api/webhooks/ # webhooki z backendu (e-maile transakcyjne) ├── components/ -│ ├── layout/ # Header, Footer, CountrySwitcher -│ ├── products/ # ProductCard, ProductGrid, Filters -│ └── search/ # SearchBar -├── contexts/ -│ ├── AuthContext.tsx # Client-side auth state -│ └── CartContext.tsx # Client-side cart state sync -└── lib/ - ├── spree/ # Spree integration helpers (auth, cookies, middleware, webhooks) - └── data/ # Server Actions - ├── addresses.ts # Address CRUD operations - ├── cart.ts # Cart operations - ├── cookies.ts # Auth token management - ├── countries.ts # Countries/regions list - ├── credit-cards.ts # Payment methods - ├── customer.ts # Auth & profile - ├── orders.ts # Order history - ├── policies.ts # Store policies - ├── products.ts # Product queries - ├── store.ts # Store configuration - └── taxonomies.ts # Categories/taxons -``` - -## Server Actions - -All data fetching is done through server actions in `src/lib/data/`. These call `@spree/sdk` directly, using helpers in `src/lib/spree/` for auth cookies and locale resolution: - -```typescript -// Products — uses getLocaleOptions() for locale-aware reads -import { getProducts, getProduct, getProductFilters } from '@/lib/data/products' - -const products = await getProducts({ limit: 12 }) -const product = await getProduct('product-slug', { expand: ['variants', 'media'] }) -const filters = await getProductFilters() - -// Cart — uses getCartOptions()/requireCartId() for cart operations -import { getCart, addToCart, updateCartItem, removeCartItem } from '@/lib/data/cart' - -const cart = await getCart() -await addToCart('var_xxx', 1) -await updateCartItem('li_xxx', 2) -await removeCartItem('li_xxx') - -// Authentication — uses withAuthRefresh() for authenticated endpoints -import { login, register, logout, getCustomer } from '@/lib/data/customer' - -const result = await login('user@example.com', 'password') -await register({ - email: 'user@example.com', - password: 'password', - password_confirmation: 'password', - first_name: 'John', - last_name: 'Doe', -}) -const customer = await getCustomer() -await logout() - -// Addresses — uses withAuthRefresh() for customer data -import { getAddresses, createAddress, updateAddress, deleteAddress } from '@/lib/data/addresses' - -const addresses = await getAddresses() -await createAddress({ first_name: 'John', ... }) -``` - -## Authentication Flow - -1. User submits login form -2. Server action calls `@spree/sdk` to authenticate -3. JWT token is stored in an httpOnly cookie via `src/lib/spree` cookie helpers -4. Subsequent requests use `withAuthRefresh()` which reads the token automatically -5. Token is never accessible to client-side JavaScript - -```typescript -// src/lib/data/customer.ts -import { getClient, withAuthRefresh, setAccessToken, setRefreshToken } from '@/lib/spree' - -export async function login(email: string, password: string) { - const result = await getClient().auth.login({ email, password }) - await setAccessToken(result.token) - await setRefreshToken(result.refresh_token) - return { success: true, user: result.user } -} - -export async function getCustomer() { - return withAuthRefresh(async (options) => { - return getClient().customer.get(options) - }) -} -``` - -## Multi-Region Support - -The storefront supports multiple countries and currencies via URL segments: - -``` -/us/en/products # US Market, English language -/de/de/products # European Market, German language -/uk/en/products # UK Market, English -``` - -Use the `CountrySwitcher` component to change [Markets](https://spreecommerce.org/docs/developer/core-concepts/markets). - -## Customization - -### Styling - -The storefront uses Tailwind CSS. Customize the design by modifying: - -- `tailwind.config.ts` - Theme configuration -- `src/app/globals.css` - Global styles - -### Components - -All components are in `src/components/` and can be customized or replaced as needed. - -### Data Layer - -To customize API behavior, modify the server actions in `src/lib/data/`. These call `@spree/sdk` directly, using helpers in `src/lib/spree/` for auth cookies and locale resolution. - -## Transactional Emails - -Customer-facing emails (order confirmation, shipping notification, password reset) are rendered in the storefront using [react-email](https://react.email) and sent via [Resend](https://resend.com). The Spree backend delivers events to the storefront via webhooks. - -### Setup - -1. **Create a webhook endpoint** in Spree Admin → Settings → Developers → Webhooks: - - Subscribe to: `order.completed`, `order.canceled`, `order.shipped`, `customer.password_reset_requested` - - Copy the secret key - -2. **Add environment variables** to `.env.local`: - -```env -SPREE_WEBHOOK_SECRET=your_webhook_endpoint_secret_key -RESEND_API_KEY=re_your_resend_api_key # production only -EMAIL_FROM=Your Store # production only +├── lib/ +│ ├── data/ # Server Actions / pobieranie danych ze Store API +│ ├── spree/ # klient SDK, middleware, konfiguracja +│ └── store.ts # nazwa sklepu, domyślny kraj/locale (env + defaulty pl) +└── types/ ``` -3. **For local development**, use [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/) to expose your storefront: +## Rozwój lokalny ```bash -brew install cloudflared -cloudflared tunnel --url http://localhost:3001 -``` - -Use the tunnel URL as the webhook endpoint URL in Spree Admin. - -No `RESEND_API_KEY` needed in dev — emails are rendered to HTML files in `.next/emails/` with a `file://` link logged to the console. - -### Email Templates - -Templates are in `src/lib/emails/` as React components: - -| Template | Event | Description | -|----------|-------|-------------| -| `order-confirmation.tsx` | `order.completed` | Order placed with items, totals, addresses | -| `order-canceled.tsx` | `order.canceled` | Cancellation notice | -| `shipment-shipped.tsx` | `order.shipped` | Shipping notification with tracking link | -| `password-reset.tsx` | `customer.password_reset_requested` | Password reset link | - -### Previewing Templates - -Run the storefront in development (`npm run dev`) and visit [http://localhost:3001/dev/emails](http://localhost:3001/dev/emails). Each template is rendered with sample data using `@react-email/render`; the route is gated to non-production environments. - -### How It Works - -``` -Spree Backend → Webhook POST → /api/webhooks/spree → render email → send via Resend -(signed HMAC) (signature verified) (react-email) (or write to disk in dev) -``` - -The webhook route handler (`src/app/api/webhooks/spree/route.ts`) uses `createWebhookHandler` from `src/lib/spree/webhooks` — signature verification and event routing are handled automatically. - -## Deployment - -### Vercel - -The easiest way to deploy is using [Vercel](https://vercel.com/new): - -1. Push your code to GitHub -2. Import the repository in Vercel -3. Add environment variables: - - `SPREE_API_URL` and `SPREE_PUBLISHABLE_KEY` (required) - - `SPREE_WEBHOOK_SECRET`, `RESEND_API_KEY`, `EMAIL_FROM` (for transactional emails) - - `GTM_ID` (optional — Google Tag Manager) - - `SENTRY_DSN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN` (optional — for error tracking with readable stack traces) -4. Deploy - -### Docker - -A multi-stage `Dockerfile` is included at the repo root. It uses Next.js standalone output to produce a small (~240 MB) image based on `node:22-alpine`, runs as a non-root user, and exposes port `3001`. - -> **Note:** `SPREE_API_URL` and `SPREE_PUBLISHABLE_KEY` are required at **build time** because the storefront prerenders pages against the Spree API. Point them at a Spree instance reachable from wherever you run `docker build` (hosted Spree, tunnel, or `host.docker.internal` for a local backend on Docker Desktop). - -**Build:** - -```bash -docker build \ - --build-arg SPREE_API_URL=https://your-spree.example.com \ - --build-arg SPREE_PUBLISHABLE_KEY=your_publishable_key \ - -t spree-storefront . -``` - -**Run:** - -```bash -docker run -p 3001:3001 --env-file .env.local spree-storefront -``` - -**Optional — Sentry source map upload at build time:** - -`SENTRY_AUTH_TOKEN` is mounted via a BuildKit secret so it never lands in image layers or the build cache. Other Sentry vars are passed as regular build args. - -```bash -SENTRY_AUTH_TOKEN=... docker build \ - --build-arg SPREE_API_URL=... \ - --build-arg SPREE_PUBLISHABLE_KEY=... \ - --build-arg SENTRY_DSN=... \ - --build-arg SENTRY_ORG=... \ - --build-arg SENTRY_PROJECT=... \ - --secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \ - -t spree-storefront . -``` - -**Building against a local Spree backend** (Docker Desktop on macOS/Windows): - -```bash -docker build \ - --add-host=host.docker.internal:host-gateway \ - --build-arg SPREE_API_URL=http://host.docker.internal:3000 \ - --build-arg SPREE_PUBLISHABLE_KEY=your_publishable_key \ - -t spree-storefront . +npm install +cp .env.local.example .env.local # SPREE_API_URL, SPREE_PUBLISHABLE_KEY +npm run dev # http://localhost:3001 -docker run -p 3001:3001 \ - --add-host=host.docker.internal:host-gateway \ - --env-file .env.local \ - spree-storefront +npm run check # Biome lint + format +npx vitest run # testy jednostkowe +npm run build # weryfikacja produkcyjna ``` -The same env vars listed under [Vercel](#vercel) apply to runtime configuration. +Backend lokalny uruchomisz z repo `sklepik` (`pnpm server:dev` → `http://localhost:3000`) albo wskaż `SPREE_API_URL` na backend produkcyjny (tylko do odczytu danych testowych). -## License +## Pochodzenie i licencja -MIT +Bazuje na forku oficjalnego [Spree Storefront](https://github.com/spree/storefront) (MIT — patrz [`LICENSE`](LICENSE)). Od momentu forka rozwijany jako storefront własnej marki. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..f0b443f9 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# Dokumentacja — sklepikFront + +Dokumenty wspólne dla całego systemu (wizja, hierarchia decyzji, architektura, stan, roadmapa) żyją w repo **`sklepik`** w `docs/` — tam jest kanon. Tutaj trzymamy wyłącznie tematy frontowe. + +| Dokument | Rola | +|---|---| +| [`kierunek-frontu.md`](kierunek-frontu.md) | Kierunek storefrontu: priorytety, ton marki, zakres MVP frontu | +| [`deployment-vercel.md`](deployment-vercel.md) | Deploy na Vercel + zmienne środowiskowe | +| [`technical-debt.md`](technical-debt.md) | Świadome skróty i dług techniczny frontu | + +Zasada: aktualizujemy istniejące pliki, nie tworzymy notatek-sierot. Historia jest w gicie. diff --git a/docs/deployment-vercel.md b/docs/deployment-vercel.md new file mode 100644 index 00000000..3b7fea61 --- /dev/null +++ b/docs/deployment-vercel.md @@ -0,0 +1,51 @@ +# Deployment storefrontu (Vercel) + +Storefront jest wdrażany na Vercel: projekt **`sklepik_front`**, produkcja `sklepikkk.vercel.app`, deploy automatyczny z gałęzi `main` tego repo. Backend, z którym rozmawia, żyje na Render (`kakaowy-sklepik.onrender.com`) — mapa całego systemu: `sklepik/docs/architektura.md`. + +## Zmienne środowiskowe + +Prawdziwe wartości ustawiamy wyłącznie w Vercel (Project Settings → Environment Variables) albo lokalnie w `.env.local` (nie commitować). W repo jest `.env.local.example`. + +### Wymagane + +| Zmienna | Rola | +|---|---| +| `SPREE_API_URL` | Adres backendu (produkcyjnie: `https://kakaowy-sklepik.onrender.com`; lokalnie `http://localhost:3000`) | +| `SPREE_PUBLISHABLE_KEY` | Publiczny klucz Store API (generowany w backendzie; to nie jest klucz Stripe) | +| `NEXT_PUBLIC_SITE_URL` | Publiczny adres storefrontu (SEO, sitemap, canonical) | + +### Ustawienia sklepu (mają polskie defaulty w kodzie — `src/lib/store.ts`) + +| Zmienna | Default | Rola | +|---|---|---| +| `NEXT_PUBLIC_STORE_NAME` | `Kakałowy Sklepik` | Nazwa sklepu | +| `NEXT_PUBLIC_DEFAULT_LOCALE` | `pl` | Domyślny język (bez prefiksu w URL; inne locale dostają `/{locale}`) | +| `NEXT_PUBLIC_DEFAULT_COUNTRY` | `pl` | Kraj do rozwiązania rynku/waluty (server-side; nie jest już segmentem URL) | +| `NEXT_PUBLIC_STORE_DESCRIPTION` | — | Opis SEO | + +### Opcjonalne + +- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` — płatności Stripe (jeszcze nieskonfigurowane; roadmapa Faza 2). +- `SPREE_WEBHOOK_SECRET`, `RESEND_API_KEY`, `EMAIL_FROM` — e-maile transakcyjne przez webhooki (`src/app/api/webhooks/spree`). +- `SENTRY_DSN` + `SENTRY_ORG` + `SENTRY_PROJECT` + `SENTRY_AUTH_TOKEN` — monitoring (wszystkie albo żadna); `*_SEND_DEFAULT_PII=true` tylko za zgodą użytkowników. + +## Test lokalny przed deployem + +```bash +npm install +npm run build && npm start # http://localhost:3001 +npx vitest run +``` + +## Checklist produkcyjny + +- [ ] `SPREE_API_URL` wskazuje publiczny backend, `SPREE_PUBLISHABLE_KEY` zgadza się ze sklepem +- [ ] `NEXT_PUBLIC_SITE_URL` = prawdziwa domena +- [ ] Sentry skonfigurowane w całości albo wcale +- [ ] Katalog, strona produktu, koszyk i checkout działają +- [ ] Obrazy produktów ładują się (z R2 przez `CDN_HOST` backendu) + +## Znane ograniczenia + +- **Cache:** produkty/rynki są cache'owane (`"use cache"` + edge Vercela) — zmiany z admina widać po ~10–15 min, dopóki nie powstanie webhook rewalidacyjny (roadmapa F4 w `sklepik/docs/roadmap.md`). +- Backend na darmowym Renderze ma cold start ~18 s po bezczynności — pierwsze żądanie po przerwie bywa wolne; to backend, nie front. diff --git a/docs/kierunek-frontu.md b/docs/kierunek-frontu.md new file mode 100644 index 00000000..8a19bd33 --- /dev/null +++ b/docs/kierunek-frontu.md @@ -0,0 +1,47 @@ +# Kierunek frontu: Kakałowy Sklepik + +## Cel repozytorium + +`sklepikFront` jest frontendową warstwą projektu Kakałowy Sklepik: premium doświadczenie zakupowe dla produktów kakao, oparte o Next.js, Store API backendu i deployment na Vercel. Repo bazuje na forku oficjalnego Spree Storefront, ale jest rozwijane jako storefront własnej marki, nie jako demo. + +Kanon całego systemu (cel, podział repo, hierarchia decyzji): `sklepik/docs/kierunek-projektu.md`. + +## Priorytety frontu + +1. Zachować działający koszyk i checkout. +2. Nie psuć integracji ze Store API. +3. Stopniowo odchodzić od generycznego wyglądu szablonu. +4. Budować premium klimat marki kakao. +5. Utrzymać dobre SEO i wydajność. +6. Izolować eksperymentalne funkcje od krytycznej ścieżki zakupowej. + +## Kierunek marki + +Robocza nazwa: **Kakałowy Sklepik**. + +Ton marki: premium, naturalny, spokojny, edukacyjny, ciepły, konkretny — bez korpo-języka i bez "marketplace vibe". Sklep ma wyglądać jak marka z historią i jakością, nie jak losowy template e-commerce. + +Klient ma dostać odpowiedzi: czym jest kakao ceremonialne, dlaczego kupić właśnie tutaj, czym różnią się produkty, jak przygotować kakao, jak działa dostawa i zwroty. + +## Zakres MVP frontu (Faza 2 roadmapy) + +- homepage z jasnym hero, +- header/footer z prostą nawigacją, +- katalog produktów i strona produktu, +- koszyk i checkout UI, +- strony: O nas, Dostawa, Zwroty, Kontakt + strony prawne, +- podstawowe SEO. + +Poza zakresem (świadomie później): gry, VOD, AI, quizy, subskrypcje, program lojalnościowy, duży redesign checkoutu. + +## Zasady zmian frontendowych + +Branding trafia głównie do: layoutu, homepage, headera, footera, komponentów marketingowych, stron informacyjnych, tekstów SEO. Checkout zmieniamy ostrożnie i tylko z jasnym powodem. + +Jeśli frontend potrzebuje nowego pola, endpointu albo logiki commerce — nie udajemy tego hardcodem; opisujemy wymaganie względem repo `sklepik`. + +## Aktualny etap (2026-07-06) + +Zrobione: rebranding podstawowy (nazwa, layout bez elementów demo), polski domyślny locale bez prefiksu URL, katalog i strony produktów działają na realnych danych z backendu. + +Przed nami (kolejność wg `sklepik/docs/roadmap.md`): rewalidacja cache po zmianach w adminie (F4), trwała idempotencja webhooków e-mail (F6), potem pełny branding premium, strony informacyjne i płatności w Fazie 2. diff --git a/docs/technical-debt.md b/docs/technical-debt.md new file mode 100644 index 00000000..2c0599a1 --- /dev/null +++ b/docs/technical-debt.md @@ -0,0 +1,55 @@ +# Skróty tymczasowe i dług techniczny + +## Cel dokumentu + +Ten dokument zapisuje świadome skróty, decyzje tymczasowe i rzeczy, które trzeba wrócić poprawić — żeby nikt nie traktował rozwiązania tymczasowego jako finalnej architektury. + +Dług dotyczący całego systemu (backend, deploy, dane) żyje w `sklepik/docs/roadmap.md` i `sklepik/docs/stan-projektu.md` — tutaj tylko skróty frontowe. + +## Statusy + +- `otwarte` — trzeba wrócić i naprawić albo doprecyzować. +- `w toku` — temat jest aktualnie rozwiązywany. +- `zamknięte` — skrót został spłacony albo decyzja stała się finalna. + +## Dług techniczny + +### 2026-07-06 — Cache storefrontu bez inwalidacji on-demand + +**Status:** otwarte + +**Skrót:** Produkty i rynki są cache'owane (`"use cache"` z TTL 10 min–godziny + edge Vercela) bez webhooka rewalidacyjnego. Zmiana w adminie jest widoczna po kilkunastu minutach. + +**Co trzeba zrobić:** endpoint webhookowy z `revalidateTag(...)` + event z backendu — zadanie **F4** w `sklepik/docs/roadmap.md`. + +**Warunek zamknięcia:** edycja w adminie widoczna w storefroncie w sekundach. + +### 2026-07-06 — Idempotencja webhooków e-mail w pamięci procesu + +**Status:** otwarte + +**Skrót:** ochrona przed duplikatami zdarzeń to `Set` w pamięci (`src/lib/webhooks/handlers.ts`); restart instancji Vercela ją zeruje — klient może dostać duplikat e-maila. + +**Co trzeba zrobić:** trwały magazyn (Redis / Postgres z unique constraint + TTL) — zadanie **F6** w `sklepik/docs/roadmap.md`. + +**Warunek zamknięcia:** restart procesu nie resetuje ochrony przed duplikatami. + +## Zamknięte + +### 2026-07-05 — Deploy frontu na Vercel przed finalnym backendem + +**Status:** zamknięte (2026-07-06) + +Backend działa publicznie na Render, front ma ustawione prawdziwe `SPREE_API_URL` + `SPREE_PUBLISHABLE_KEY`, katalog renderuje realne produkty. Pełna weryfikacja checkoutu z płatnościami to Faza 2 roadmapy (Stripe jeszcze nieskonfigurowany). + +### 2026-07-05 — Branding przed pełnym finalnym deploymentem commerce + +**Status:** zamknięte (2026-07-06) + +Rebranding "Kakałowy Sklepik" wdrożony (nazwa, layout, locale-only URLs, usunięte linki demo Spree); w backendzie zseedowano 6 realnych produktów kakao. Dalszy branding premium to normalna praca Fazy 2, nie dług. + +### 2026-07-05 — Vercel Commerce wymaga adaptera Spree + +**Status:** zamknięte (2026-07-06) — **kierunek odrzucony** + +Brak ROI: `@spree/sdk` + obecny storefront realizują ten sam zakres, a adaptacja `vercel/commerce` (utrzymywanego pod Shopify) wymagałaby napisania i utrzymywania całego adaptera provider→Spree. Żaden dokument w repo nie traktuje już migracji na Vercel Commerce jako aktywnego planu. diff --git a/src/app/[country]/[locale]/(storefront)/page.tsx b/src/app/[country]/[locale]/(storefront)/page.tsx deleted file mode 100644 index a5bc2456..00000000 --- a/src/app/[country]/[locale]/(storefront)/page.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { Metadata } from "next"; -import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection"; -import { HeroSection } from "@/components/home/HeroSection"; -import { getMarkets, resolveCurrency } from "@/lib/data/markets"; -import { generateHomeMetadata } from "@/lib/metadata/home"; -import { getDefaultCountry, getDefaultLocale } from "@/lib/store"; - -interface HomePageProps { - params: Promise<{ - country: string; - locale: string; - }>; -} - -/** - * Prebuild the homepage shell for every (country, locale) combination the - * store serves. Next.js reuses the static shell (hero + featured section - * chrome) while featured products stream in under Suspense. - * - * Cache Components requires this to return at least one entry, so we - * always include the store's configured default country/locale as a - * fallback even if the markets fetch fails. - */ -export async function generateStaticParams() { - const fallback = { - country: getDefaultCountry(), - locale: getDefaultLocale(), - }; - - let markets; - try { - ({ data: markets } = await getMarkets()); - } catch { - return [fallback]; - } - - const params: Array<{ country: string; locale: string }> = []; - const seen = new Set(); - - const addParam = (country: string, locale: string) => { - const key = `${country}/${locale}`; - if (seen.has(key)) return; - seen.add(key); - params.push({ country, locale }); - }; - - for (const market of markets) { - const locale = market.default_locale; - if (!locale) continue; - for (const country of market.countries ?? []) { - const iso = country.iso?.toLowerCase(); - if (!iso) continue; - addParam(iso, locale); - } - } - - if (params.length === 0) { - addParam(fallback.country, fallback.locale); - } - - return params; -} - -export async function generateMetadata({ - params, -}: HomePageProps): Promise { - const { country, locale } = await params; - return generateHomeMetadata({ country, locale }); -} - -export default async function HomePage({ params }: HomePageProps) { - const { country, locale } = await params; - const basePath = `/${country}/${locale}`; - const currency = await resolveCurrency(country); - - return ( -
- - -
- ); -} diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx b/src/app/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx similarity index 99% rename from src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx rename to src/app/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx index f86b85a3..1a99bc9a 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx +++ b/src/app/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx @@ -47,6 +47,7 @@ import { completeCheckoutOrder, completeCheckoutPaymentSession, } from "@/lib/data/payment"; +import { getDefaultCountry } from "@/lib/store"; import { extractBasePath } from "@/lib/utils/path"; import { CheckoutSidebar } from "./CheckoutSidebar"; import type { CheckoutInitialData } from "./page"; @@ -77,13 +78,11 @@ function cartItemsFingerprint(cart: Cart | null): string { interface CheckoutPageContentProps { cartId: string; - urlCountry: string; initialData: CheckoutInitialData | null; } function CheckoutPageContentInner({ cartId, - urlCountry, initialData, }: CheckoutPageContentProps) { const router = useRouter(); @@ -201,7 +200,7 @@ function CheckoutPageContentInner({ try { const [cartData, market, addressesData, authStatus] = await Promise.all([ getCheckoutOrder(cartId), - resolveMarket(urlCountry).catch(() => null), + resolveMarket(getDefaultCountry()).catch(() => null), getAddresses(), checkAuth(), ]); @@ -235,7 +234,7 @@ function CheckoutPageContentInner({ } finally { setLoading(false); } - }, [cartId, urlCountry, basePath, paymentError]); + }, [cartId, basePath, paymentError]); // Only fetch on mount if we don't have initial data (e.g. client-side navigation) useEffect(() => { diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx b/src/app/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx similarity index 100% rename from src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx rename to src/app/[locale]/(checkout)/checkout/[id]/CheckoutSidebar.tsx diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[locale]/(checkout)/checkout/[id]/page.tsx similarity index 85% rename from src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx rename to src/app/[locale]/(checkout)/checkout/[id]/page.tsx index 8ba5908e..f07e1a8c 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[locale]/(checkout)/checkout/[id]/page.tsx @@ -7,6 +7,8 @@ import { getCheckoutOrder } from "@/lib/data/checkout"; import { isAuthenticated as checkAuth } from "@/lib/data/cookies"; import { getCountry } from "@/lib/data/countries"; import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; +import { getDefaultCountry } from "@/lib/store"; +import { buildBasePath } from "@/lib/utils/path"; import { CheckoutPageContent } from "./CheckoutPageContent"; @@ -20,7 +22,6 @@ export interface CheckoutInitialData { interface CheckoutPageProps { params: Promise<{ id: string; - country: string; locale: string; }>; } @@ -28,7 +29,7 @@ interface CheckoutPageProps { async function CheckoutDataLoader({ params }: CheckoutPageProps) { await connection(); - const { id: cartId, country: urlCountry } = await params; + const { id: cartId, locale } = await params; // Check auth first so we can skip address fetch for guests const authStatus = await checkAuth(); @@ -36,14 +37,13 @@ async function CheckoutDataLoader({ params }: CheckoutPageProps) { // Fetch initial data in parallel during SSR const [cartData, market, addressesData] = await Promise.all([ getCheckoutOrder(cartId), - resolveMarket(urlCountry).catch(() => null), + resolveMarket(getDefaultCountry()).catch(() => null), authStatus ? getAddresses() : Promise.resolve({ data: [] as Address[] }), ]); // Redirect to order-placed if already complete if (cartData?.current_step === "complete") { - const basePath = `/${urlCountry}/en`; - redirect(`${basePath}/order-placed/${cartId}`); + redirect(`${buildBasePath(locale)}/order-placed/${cartId}`); } const countriesData = market @@ -68,13 +68,7 @@ async function CheckoutDataLoader({ params }: CheckoutPageProps) { } : null; - return ( - - ); + return ; } export default function CheckoutPage({ params }: CheckoutPageProps) { diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx b/src/app/[locale]/(checkout)/confirm-payment/[id]/page.tsx similarity index 99% rename from src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx rename to src/app/[locale]/(checkout)/confirm-payment/[id]/page.tsx index f9f02423..1e22874b 100644 --- a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx +++ b/src/app/[locale]/(checkout)/confirm-payment/[id]/page.tsx @@ -10,7 +10,6 @@ import { extractBasePath } from "@/lib/utils/path"; interface ConfirmPaymentPageProps { params: Promise<{ id: string; - country: string; locale: string; }>; } diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx b/src/app/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx similarity index 97% rename from src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx rename to src/app/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx index de147221..543513e8 100644 --- a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx +++ b/src/app/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx @@ -42,7 +42,7 @@ import ConfirmPaymentPage from "../[id]/page"; const mockConfirm = vi.mocked(confirmPaymentAndCompleteCart); -function renderPage(params = { id: "cart-1", country: "us", locale: "en" }) { +function renderPage(params = { id: "cart-1", locale: "en" }) { const resolvedParams = Promise.resolve(params); return render( suspense-fallback}> @@ -192,7 +192,6 @@ describe("ConfirmPaymentPage", () => { diff --git a/src/app/[country]/[locale]/(checkout)/layout.tsx b/src/app/[locale]/(checkout)/layout.tsx similarity index 95% rename from src/app/[country]/[locale]/(checkout)/layout.tsx rename to src/app/[locale]/(checkout)/layout.tsx index 35c227d6..1fb50977 100644 --- a/src/app/[country]/[locale]/(checkout)/layout.tsx +++ b/src/app/[locale]/(checkout)/layout.tsx @@ -1,7 +1,6 @@ "use client"; import { ArrowLeft, ChevronDown, ShoppingBag } from "lucide-react"; -import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -25,14 +24,7 @@ function CheckoutHeader() { return (
- {storeName} + {storeName} ; } diff --git a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx b/src/app/[locale]/(storefront)/account/addresses/page.tsx similarity index 92% rename from src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx rename to src/app/[locale]/(storefront)/account/addresses/page.tsx index d0e1db8f..5b9938a1 100644 --- a/src/app/[country]/[locale]/(storefront)/account/addresses/page.tsx +++ b/src/app/[locale]/(storefront)/account/addresses/page.tsx @@ -6,21 +6,22 @@ import type { User } from "@/contexts/AuthContext"; import { getAddresses } from "@/lib/data/addresses"; import { getCustomer } from "@/lib/data/customer"; import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; +import { getDefaultCountry } from "@/lib/store"; interface AddressesPageProps { - params: Promise<{ country: string; locale: string }>; + params: Promise<{ locale: string }>; } export default async function AddressesPage({ params }: AddressesPageProps) { await connection(); - const { country: urlCountry, locale } = await params; + const { locale } = await params; const t = await getTranslations({ locale: locale as Locale, namespace: "account", }); const [addressResponse, market, customer] = await Promise.all([ getAddresses(), - resolveMarket(urlCountry).catch(() => null), + resolveMarket(getDefaultCountry()).catch(() => null), getCustomer().catch(() => null), ]); diff --git a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx b/src/app/[locale]/(storefront)/account/credit-cards/page.tsx similarity index 96% rename from src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx rename to src/app/[locale]/(storefront)/account/credit-cards/page.tsx index 6ae6099a..bf88c7df 100644 --- a/src/app/[country]/[locale]/(storefront)/account/credit-cards/page.tsx +++ b/src/app/[locale]/(storefront)/account/credit-cards/page.tsx @@ -5,7 +5,7 @@ import { CreditCardList } from "@/components/account/CreditCardList"; import { getCreditCards } from "@/lib/data/credit-cards"; interface CreditCardsPageProps { - params: Promise<{ country: string; locale: string }>; + params: Promise<{ locale: string }>; } export default async function CreditCardsPage({ diff --git a/src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx b/src/app/[locale]/(storefront)/account/forgot-password/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/forgot-password/page.tsx rename to src/app/[locale]/(storefront)/account/forgot-password/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx b/src/app/[locale]/(storefront)/account/gift-cards/page.tsx similarity index 96% rename from src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx rename to src/app/[locale]/(storefront)/account/gift-cards/page.tsx index 7331b23e..215f1abf 100644 --- a/src/app/[country]/[locale]/(storefront)/account/gift-cards/page.tsx +++ b/src/app/[locale]/(storefront)/account/gift-cards/page.tsx @@ -5,7 +5,7 @@ import { GiftCardList } from "@/components/account/GiftCardList"; import { getGiftCards } from "@/lib/data/gift-cards"; interface GiftCardsPageProps { - params: Promise<{ country: string; locale: string }>; + params: Promise<{ locale: string }>; } export default async function GiftCardsPage({ params }: GiftCardsPageProps) { diff --git a/src/app/[country]/[locale]/(storefront)/account/layout.tsx b/src/app/[locale]/(storefront)/account/layout.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/layout.tsx rename to src/app/[locale]/(storefront)/account/layout.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx b/src/app/[locale]/(storefront)/account/orders/[id]/page.tsx similarity index 89% rename from src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx rename to src/app/[locale]/(storefront)/account/orders/[id]/page.tsx index 72d24331..91410e1f 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/[id]/page.tsx +++ b/src/app/[locale]/(storefront)/account/orders/[id]/page.tsx @@ -3,10 +3,10 @@ import { connection } from "next/server"; import { getTranslations } from "next-intl/server"; import { OrderDetail } from "@/components/account/OrderDetail"; import { getOrder } from "@/lib/data/orders"; +import { buildBasePath } from "@/lib/utils/path"; interface OrderDetailPageProps { params: Promise<{ - country: string; locale: string; id: string; }>; @@ -16,12 +16,12 @@ export default async function OrderDetailPage({ params, }: OrderDetailPageProps) { await connection(); - const { country, locale, id } = await params; + const { locale, id } = await params; const t = await getTranslations({ locale: locale as Locale, namespace: "orders", }); - const basePath = `/${country}/${locale}`; + const basePath = buildBasePath(locale); const order = await getOrder(id); if (!order || order.completed_at === null) { diff --git a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx b/src/app/[locale]/(storefront)/account/orders/page.tsx similarity index 89% rename from src/app/[country]/[locale]/(storefront)/account/orders/page.tsx rename to src/app/[locale]/(storefront)/account/orders/page.tsx index d89bb56c..0f3298e3 100644 --- a/src/app/[country]/[locale]/(storefront)/account/orders/page.tsx +++ b/src/app/[locale]/(storefront)/account/orders/page.tsx @@ -5,19 +5,20 @@ import { getTranslations } from "next-intl/server"; import { OrderList } from "@/components/account/OrderList"; import { Button } from "@/components/ui/button"; import { getOrders } from "@/lib/data/orders"; +import { buildBasePath } from "@/lib/utils/path"; interface OrdersPageProps { - params: Promise<{ country: string; locale: string }>; + params: Promise<{ locale: string }>; } export default async function OrdersPage({ params }: OrdersPageProps) { await connection(); - const { country, locale } = await params; + const { locale } = await params; const t = await getTranslations({ locale: locale as Locale, namespace: "orders", }); - const basePath = `/${country}/${locale}`; + const basePath = buildBasePath(locale); const response = await getOrders({ limit: 50 }); const orders = response.data.filter((order) => order.completed_at !== null); diff --git a/src/app/[country]/[locale]/(storefront)/account/page.tsx b/src/app/[locale]/(storefront)/account/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/page.tsx rename to src/app/[locale]/(storefront)/account/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/profile/page.tsx b/src/app/[locale]/(storefront)/account/profile/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/profile/page.tsx rename to src/app/[locale]/(storefront)/account/profile/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/register/page.tsx b/src/app/[locale]/(storefront)/account/register/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/register/page.tsx rename to src/app/[locale]/(storefront)/account/register/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx b/src/app/[locale]/(storefront)/account/reset-password/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/account/reset-password/page.tsx rename to src/app/[locale]/(storefront)/account/reset-password/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryBanner.tsx b/src/app/[locale]/(storefront)/c/[...permalink]/CategoryBanner.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/c/[...permalink]/CategoryBanner.tsx rename to src/app/[locale]/(storefront)/c/[...permalink]/CategoryBanner.tsx diff --git a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx b/src/app/[locale]/(storefront)/c/[...permalink]/page.tsx similarity index 87% rename from src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx rename to src/app/[locale]/(storefront)/c/[...permalink]/page.tsx index 2fe2035e..2b4c5bb9 100644 --- a/src/app/[country]/[locale]/(storefront)/c/[...permalink]/page.tsx +++ b/src/app/[locale]/(storefront)/c/[...permalink]/page.tsx @@ -7,13 +7,13 @@ import { resolveCurrency } from "@/lib/data/markets"; import { getProductFilters } from "@/lib/data/products"; import { generateCategoryMetadata } from "@/lib/metadata/category"; import { buildBreadcrumbJsonLd } from "@/lib/seo"; -import { getStoreUrl } from "@/lib/store"; +import { getDefaultCountry, getStoreUrl } from "@/lib/store"; import { parseListingSearchParams } from "@/lib/utils/listing-search-params"; +import { buildBasePath } from "@/lib/utils/path"; import { CategoryBanner } from "./CategoryBanner"; interface CategoryPageProps { params: Promise<{ - country: string; locale: string; permalink: string[]; }>; @@ -23,18 +23,18 @@ interface CategoryPageProps { export async function generateMetadata({ params, }: CategoryPageProps): Promise { - const { country, locale, permalink } = await params; - return generateCategoryMetadata({ country, locale, permalink }); + const { locale, permalink } = await params; + return generateCategoryMetadata({ locale, permalink }); } export default async function CategoryPage({ params, searchParams, }: CategoryPageProps) { - const { country, locale, permalink } = await params; + const { locale, permalink } = await params; const rawSearchParams = await searchParams; const fullPermalink = permalink.join("/"); - const basePath = `/${country}/${locale}`; + const basePath = buildBasePath(locale); let category; try { @@ -51,7 +51,7 @@ export default async function CategoryPage({ } const storeUrl = getStoreUrl(); - const currency = await resolveCurrency(country); + const currency = await resolveCurrency(getDefaultCountry()); const listingState = parseListingSearchParams(rawSearchParams); // Pre-bind categoryId onto the server action so the client-side diff --git a/src/app/[country]/[locale]/(storefront)/cart/page.tsx b/src/app/[locale]/(storefront)/cart/page.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/cart/page.tsx rename to src/app/[locale]/(storefront)/cart/page.tsx diff --git a/src/app/[country]/[locale]/(storefront)/layout.tsx b/src/app/[locale]/(storefront)/layout.tsx similarity index 91% rename from src/app/[country]/[locale]/(storefront)/layout.tsx rename to src/app/[locale]/(storefront)/layout.tsx index 1ae28ecd..7b9eb09e 100644 --- a/src/app/[country]/[locale]/(storefront)/layout.tsx +++ b/src/app/[locale]/(storefront)/layout.tsx @@ -3,10 +3,11 @@ import Link from "next/link"; import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; import { getCategories } from "@/lib/data/categories"; +import { buildBasePath } from "@/lib/utils/path"; interface StorefrontLayoutProps { children: React.ReactNode; - params: Promise<{ country: string; locale: string }>; + params: Promise<{ locale: string }>; } function CategoryLinks({ @@ -36,8 +37,8 @@ export default async function StorefrontLayout({ children, params, }: StorefrontLayoutProps) { - const { country, locale } = await params; - const basePath = `/${country}/${locale}`; + const { locale } = await params; + const basePath = buildBasePath(locale); const rootCategories = await getCategories({ depth_eq: 0, diff --git a/src/app/[locale]/(storefront)/page.tsx b/src/app/[locale]/(storefront)/page.tsx new file mode 100644 index 00000000..f98057d2 --- /dev/null +++ b/src/app/[locale]/(storefront)/page.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from "next"; +import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection"; +import { HeroSection } from "@/components/home/HeroSection"; +import { resolveCurrency } from "@/lib/data/markets"; +import { generateHomeMetadata } from "@/lib/metadata/home"; +import { + getDefaultCountry, + getDefaultLocale, + getPrefixedLocales, +} from "@/lib/store"; +import { buildBasePath } from "@/lib/utils/path"; + +interface HomePageProps { + params: Promise<{ + locale: string; + }>; +} + +/** + * Prebuild the homepage shell for every locale the store serves (the + * default, unprefixed locale plus every locale with a URL prefix). Next.js + * reuses the static shell (hero + featured section chrome) while featured + * products stream in under Suspense. + */ +export async function generateStaticParams() { + const defaultLocale = getDefaultLocale(); + const locales = [ + defaultLocale, + ...getPrefixedLocales().filter((l) => l !== defaultLocale), + ]; + return locales.map((locale) => ({ locale })); +} + +export async function generateMetadata({ + params, +}: HomePageProps): Promise { + const { locale } = await params; + return generateHomeMetadata({ locale }); +} + +export default async function HomePage({ params }: HomePageProps) { + const { locale } = await params; + const basePath = buildBasePath(locale); + const currency = await resolveCurrency(getDefaultCountry()); + + return ( +
+ + +
+ ); +} diff --git a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx b/src/app/[locale]/(storefront)/policies/[slug]/page.tsx similarity index 98% rename from src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx rename to src/app/[locale]/(storefront)/policies/[slug]/page.tsx index 61353a48..1f87d75c 100644 --- a/src/app/[country]/[locale]/(storefront)/policies/[slug]/page.tsx +++ b/src/app/[locale]/(storefront)/policies/[slug]/page.tsx @@ -6,7 +6,6 @@ import { getStoreName } from "@/lib/store"; interface PolicyPageProps { params: Promise<{ - country: string; locale: string; slug: string; }>; diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx b/src/app/[locale]/(storefront)/products/[slug]/ProductDetails.tsx similarity index 100% rename from src/app/[country]/[locale]/(storefront)/products/[slug]/ProductDetails.tsx rename to src/app/[locale]/(storefront)/products/[slug]/ProductDetails.tsx diff --git a/src/app/[country]/[locale]/(storefront)/products/[slug]/page.tsx b/src/app/[locale]/(storefront)/products/[slug]/page.tsx similarity index 76% rename from src/app/[country]/[locale]/(storefront)/products/[slug]/page.tsx rename to src/app/[locale]/(storefront)/products/[slug]/page.tsx index e57c1f7d..82a84550 100644 --- a/src/app/[country]/[locale]/(storefront)/products/[slug]/page.tsx +++ b/src/app/[locale]/(storefront)/products/[slug]/page.tsx @@ -11,11 +11,11 @@ import { buildProductJsonLd, } from "@/lib/seo"; import { getStoreUrl } from "@/lib/store"; +import { buildBasePath } from "@/lib/utils/path"; import { ProductDetails } from "./ProductDetails"; interface ProductPageProps { params: Promise<{ - country: string; locale: string; slug: string; }>; @@ -27,8 +27,8 @@ interface ProductPageProps { export async function generateMetadata({ params, }: ProductPageProps): Promise { - const { country, locale, slug } = await params; - return generateProductMetadata({ country, locale, slug }); + const { locale, slug } = await params; + return generateProductMetadata({ locale, slug }); } function findBreadcrumbCategory( @@ -47,11 +47,11 @@ export default async function ProductPage({ params, searchParams, }: ProductPageProps) { - const { country, locale, slug } = await params; + const { locale, slug } = await params; const { category_id } = await searchParams; - const basePath = `/${country}/${locale}`; + const basePath = buildBasePath(locale); - let product; + let product: Awaited>; try { product = await getCachedProduct(slug, PRODUCT_PAGE_EXPAND); } catch { @@ -60,27 +60,24 @@ export default async function ProductPage({ const storeUrl = getStoreUrl(); const canonicalUrl = storeUrl - ? buildCanonicalUrl( - storeUrl, - `/${country}/${locale}/products/${product.slug}`, - ) + ? buildCanonicalUrl(storeUrl, `${basePath}/products/${product!.slug}`) : undefined; const breadcrumbCategory = findBreadcrumbCategory( - product.categories || [], + product!.categories || [], category_id, ); return ( <> {canonicalUrl && ( - + )} {breadcrumbCategory && storeUrl && ( )} @@ -89,12 +86,12 @@ export default async function ProductPage({ )} - + ); } diff --git a/src/app/[country]/[locale]/(storefront)/products/page.tsx b/src/app/[locale]/(storefront)/products/page.tsx similarity index 86% rename from src/app/[country]/[locale]/(storefront)/products/page.tsx rename to src/app/[locale]/(storefront)/products/page.tsx index 5cb2e02c..715fd3a8 100644 --- a/src/app/[country]/[locale]/(storefront)/products/page.tsx +++ b/src/app/[locale]/(storefront)/products/page.tsx @@ -4,11 +4,12 @@ import { ProductListing } from "@/components/products/ProductListing"; import { resolveCurrency } from "@/lib/data/markets"; import { getProductFilters, getProducts } from "@/lib/data/products"; import { generateProductsMetadata } from "@/lib/metadata/products"; +import { getDefaultCountry } from "@/lib/store"; import { parseListingSearchParams } from "@/lib/utils/listing-search-params"; +import { buildBasePath } from "@/lib/utils/path"; interface ProductsPageProps { params: Promise<{ - country: string; locale: string; }>; searchParams: Promise>; @@ -17,18 +18,18 @@ interface ProductsPageProps { export async function generateMetadata({ params, }: ProductsPageProps): Promise { - const { country, locale } = await params; - return generateProductsMetadata({ country, locale }); + const { locale } = await params; + return generateProductsMetadata({ locale }); } export default async function ProductsPage({ params, searchParams, }: ProductsPageProps) { - const { country, locale } = await params; + const { locale } = await params; const rawSearchParams = await searchParams; - const basePath = `/${country}/${locale}`; - const currency = await resolveCurrency(country); + const basePath = buildBasePath(locale); + const currency = await resolveCurrency(getDefaultCountry()); const listingState = parseListingSearchParams(rawSearchParams); const query = listingState.query; diff --git a/src/app/[country]/[locale]/layout.tsx b/src/app/[locale]/layout.tsx similarity index 58% rename from src/app/[country]/[locale]/layout.tsx rename to src/app/[locale]/layout.tsx index 0040345a..e984f439 100644 --- a/src/app/[country]/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { redirect } from "next/navigation"; import { NextIntlClientProvider } from "next-intl"; import { CartDrawer } from "@/components/cart/CartDrawer"; import { JsonLd } from "@/components/seo/JsonLd"; @@ -10,12 +9,12 @@ import { StoreProvider } from "@/contexts/StoreContext"; import { getMarkets } from "@/lib/data/markets"; import { generateStoreMetadata } from "@/lib/metadata/store"; import { buildOrganizationJsonLd } from "@/lib/seo"; -import { getDefaultCountry, getDefaultLocale } from "@/lib/store"; -import deMessages from "../../../../messages/de.json"; -import enMessages from "../../../../messages/en.json"; -import esMessages from "../../../../messages/es.json"; -import frMessages from "../../../../messages/fr.json"; -import plMessages from "../../../../messages/pl.json"; +import { getDefaultCountry } from "@/lib/store"; +import deMessages from "../../../messages/de.json"; +import enMessages from "../../../messages/en.json"; +import esMessages from "../../../messages/es.json"; +import frMessages from "../../../messages/fr.json"; +import plMessages from "../../../messages/pl.json"; const messagesMap: Record = { en: enMessages, @@ -25,48 +24,35 @@ const messagesMap: Record = { pl: plMessages, }; -interface CountryLocaleLayoutProps { +interface LocaleLayoutProps { children: React.ReactNode; params: Promise<{ - country: string; locale: string; }>; } export async function generateMetadata({ params, -}: CountryLocaleLayoutProps): Promise { +}: LocaleLayoutProps): Promise { const { locale } = await params; return generateStoreMetadata({ locale }); } -export default async function CountryLocaleLayout({ +export default async function LocaleLayout({ children, params, -}: CountryLocaleLayoutProps) { - const { country, locale } = await params; +}: LocaleLayoutProps) { + const { locale } = await params; + const country = getDefaultCountry(); + // Single-market store: the country used for pricing/markets is a fixed + // server default, never derived from the URL. Fetch failures here just + // mean an empty country-switcher list — not a redirect, so there's no + // way for this to loop. const markets = await getMarkets({ country, locale }) .then((res) => res.data) .catch(() => []); - // Validate that the URL country belongs to an available market. - // If not, redirect server-side to avoid SSR with wrong prices. - const isValidCountry = markets.some((market) => - market.countries?.some( - (c) => c.iso.toLowerCase() === country.toLowerCase(), - ), - ); - - if (!isValidCountry) { - const defaultMarket = markets.find((m) => m.default) ?? markets[0]; - const fallbackCountry = - defaultMarket?.countries?.[0]?.iso.toLowerCase() ?? getDefaultCountry(); - const fallbackLocale = defaultMarket?.default_locale ?? getDefaultLocale(); - - redirect(`/${fallbackCountry}/${fallbackLocale}`); - } - // Load messages statically (no runtime data access) to avoid blocking prerender const messages = messagesMap[locale] || messagesMap.en; diff --git a/src/app/robots.ts b/src/app/robots.ts index 70e4857e..bc1070a7 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -12,6 +12,11 @@ export default async function robots(): Promise { userAgent: "*", allow: "/", disallow: [ + "/account", + "/account/*", + "/cart", + "/checkout", + "/checkout/*", "/*/account", "/*/account/*", "/*/cart", diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 5fad5159..e3208e25 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,11 @@ import type { Category, Media, Product } from "@spree/sdk"; import { getClient } from "@/lib/spree"; -import { getDefaultCountry, getDefaultLocale, getStoreUrl } from "@/lib/store"; +import { + getDefaultCountry, + getDefaultLocale, + getPrefixedLocales, + getStoreUrl, +} from "@/lib/store"; type ProductWithMedia = Product & { media?: Media[]; @@ -15,11 +20,6 @@ import type { MetadataRoute } from "next"; export const dynamic = "force-dynamic"; -interface CountryLocale { - country: string; - locale: string; -} - interface LocaleOptions { locale: string; country: string; @@ -58,12 +58,30 @@ const cachedCategoriesByLocale = new Map< string, Promise >(); -let cachedCountryLocales: Promise | null = null; function localeCacheKey(locale: string, country: string): string { return `${locale}:${country}`; } +/** + * Locales included in the sitemap: the default (unprefixed) locale plus + * every locale the middleware serves under a `/{locale}` prefix. This is a + * single-market store — the country used for API calls is always the fixed + * default, never derived from the URL. + */ +function resolveLocales(): string[] { + const defaultLocale = getDefaultLocale(); + return [ + defaultLocale, + ...getPrefixedLocales().filter((l) => l !== defaultLocale), + ]; +} + +/** Base path for a locale: no prefix for the default locale, `/{locale}` otherwise. */ +function basePathFor(baseUrl: string, locale: string): string { + return locale === getDefaultLocale() ? baseUrl : `${baseUrl}/${locale}`; +} + function getCachedProducts( localeOpts: LocaleOptions, ): Promise { @@ -94,16 +112,6 @@ function getCachedCategories( return cached; } -function getCachedCountryLocales(): Promise { - if (!cachedCountryLocales) { - cachedCountryLocales = resolveCountryLocales().catch((err) => { - cachedCountryLocales = null; - throw err; - }); - } - return cachedCountryLocales; -} - /** * Splits the sitemap into multiple files when the total URL count * exceeds 50,000 (Google's per-sitemap limit). @@ -115,7 +123,7 @@ function getCachedCountryLocales(): Promise { */ export async function generateSitemaps(): Promise> { try { - const countryLocales = await getCachedCountryLocales(); + const locales = resolveLocales(); // Lightweight count — fetch only 1 record per request to read meta.count. // Category count is approximate (includes root categories filtered out during generation), @@ -129,7 +137,7 @@ export async function generateSitemaps(): Promise> { STATIC_PAGES_PER_LOCALE + Math.min(productCount, MAX_FETCHABLE_ITEMS) + Math.min(categoryCount, MAX_FETCHABLE_ITEMS); - const totalUrls = urlsPerLocale * countryLocales.length; + const totalUrls = urlsPerLocale * locales.length; const sitemapCount = Math.max(1, Math.ceil(totalUrls / URLS_PER_SITEMAP)); return Array.from({ length: sitemapCount }, (_, i) => ({ id: i })); @@ -163,21 +171,15 @@ export default async function sitemap(props: { return []; } - let countryLocales: CountryLocale[]; - - try { - countryLocales = await getCachedCountryLocales(); - } catch (err) { - console.error("Sitemap generation failed: API unavailable.", err); - return []; - } + const locales = resolveLocales(); + const country = getDefaultCountry(); // Build entries for all locales, then slice to the requested chunk. // For most stores (< 50k URLs) this produces a single chunk so no slicing occurs. const entries: MetadataRoute.Sitemap = []; - for (const { country, locale } of countryLocales) { - const basePath = `${baseUrl}/${country}/${locale}`; + for (const locale of locales) { + const basePath = basePathFor(baseUrl, locale); const localeOpts: LocaleOptions = { locale, country }; let products: ProductWithMedia[]; @@ -254,35 +256,6 @@ export default async function sitemap(props: { return entries.slice(start, start + URLS_PER_SITEMAP); } -/** - * Resolves the list of country/locale pairs to include in the sitemap - * by fetching all markets from the Spree API. Each market contains its - * countries and default locale, so no env-based configuration is needed. - */ -async function resolveCountryLocales(): Promise { - const localeOptions = getDefaultLocaleOptions(); - const { data: markets } = await getClient().markets.list(localeOptions); - - const seen = new Set(); - const result: CountryLocale[] = []; - - for (const market of markets) { - for (const country of market.countries ?? []) { - const iso = country.iso.toLowerCase(); - if (seen.has(iso)) continue; - seen.add(iso); - result.push({ - country: iso, - locale: market.default_locale || localeOptions.locale, - }); - } - } - - return result.length > 0 - ? result - : [{ country: localeOptions.country, locale: localeOptions.locale }]; -} - /** * Fetches only the total count for products or categories without loading all data. * Used by generateSitemaps() to calculate the number of sitemap files needed. diff --git a/src/components/home/HeroSection.tsx b/src/components/home/HeroSection.tsx index 0e34f6f7..5110b1d6 100644 --- a/src/components/home/HeroSection.tsx +++ b/src/components/home/HeroSection.tsx @@ -15,11 +15,6 @@ export async function HeroSection({ basePath, locale }: HeroSectionProps) { }); const storeName = getStoreName(); - /* Demo-only: Remove for production. */ - const githubUrl = "https://github.com/spree/storefront"; - const quickstartUrl = - "https://spreecommerce.org/docs/developer/getting-started/quickstart"; - return (
@@ -34,21 +29,6 @@ export async function HeroSection({ basePath, locale }: HeroSectionProps) { - {/* Demo-only: Remove for production. */} - -
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 0e3622a9..b3cf95d9 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -8,12 +8,6 @@ import { CurrentYear } from "./CurrentYear"; const storeName = getStoreName(); const storeDescription = getStoreDescription(); -// Demo-only: Remove for production. -const githubUrl = "https://github.com/spree/storefront"; -const quickstartUrl = - "https://spreecommerce.org/docs/developer/getting-started/quickstart"; -const learnMoreUrl = "https://spreecommerce.org"; - interface FooterProps { rootCategories: Category[]; basePath: string; @@ -32,40 +26,12 @@ export async function Footer({