feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform: - apps/storefront: full customer-facing Next.js app with HeroUI (cart, checkout, orders, wishlist, product detail, shop, search, auth) - convex/: serverless backend with schema, queries, mutations, actions, HTTP routes, Stripe/Shippo integrations, and co-located tests - packages/types, packages/utils, packages/convex: shared workspace packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
.next
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
out
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Convex
|
||||||
|
convex/_generated
|
||||||
153
CLAUDE.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**The Pet Loft** — a full-stack ecommerce platform for pet supplies. Turborepo monorepo with two Next.js apps, a shared Convex backend, and shared packages.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
Two processes must run simultaneously for full-stack development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Convex backend (must run first)
|
||||||
|
npx convex dev
|
||||||
|
|
||||||
|
# Terminal 2 — Next.js apps
|
||||||
|
npm run dev # Both apps in parallel
|
||||||
|
npm run dev:storefront # Storefront only (port 3000)
|
||||||
|
npm run dev:admin # Admin only (port 3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run type-check # TypeScript check across all workspaces
|
||||||
|
npm run lint # ESLint via Turbo
|
||||||
|
npm run test # Vitest watch mode (edge-runtime)
|
||||||
|
npm run test:once # Run all tests once
|
||||||
|
npm run test:coverage # Coverage report
|
||||||
|
npm run build # Build both apps
|
||||||
|
```
|
||||||
|
|
||||||
|
To run a single test file: `npx vitest run convex/carts.test.ts`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Monorepo Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/storefront/ # Customer store — Next.js + HeroUI (port 3000)
|
||||||
|
apps/admin/ # Staff dashboard — Next.js + ShadCN (port 3001)
|
||||||
|
convex/ # Serverless backend: schema, functions, HTTP routes, tests
|
||||||
|
packages/types/ # Shared TypeScript interfaces (Product, Order, User…)
|
||||||
|
packages/utils/ # Shared helpers: formatPrice, slugify, formatDate
|
||||||
|
packages/convex/ # ConvexClientProvider (Clerk + Convex integration)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend: Convex
|
||||||
|
|
||||||
|
All backend logic lives in `convex/`. The schema defines 11 tables:
|
||||||
|
|
||||||
|
- **users** — Clerk-synced; roles: `customer`, `admin`, `super_admin`
|
||||||
|
- **products** / **productImages** / **productVariants** — catalog with SKUs
|
||||||
|
- **categories** — hierarchical (parent + topCategory slugs)
|
||||||
|
- **carts** — guest (`sessionId`) or authenticated (`userId`), 30-day expiry
|
||||||
|
- **orders** / **orderItems** — price/address snapshots at order time
|
||||||
|
- **addresses** — shipping/billing with validation flag
|
||||||
|
- **reviews** / **wishlists** — community features
|
||||||
|
|
||||||
|
Business logic is extracted into `convex/model/*.ts` helpers and reused across public functions.
|
||||||
|
|
||||||
|
### Auth Flow
|
||||||
|
|
||||||
|
1. Clerk handles UI + JWT. `ConvexProviderWithClerk` (from `@repo/convex`) passes the JWT to Convex.
|
||||||
|
2. `convex/auth.config.ts` trusts the Clerk JWT issuer domain.
|
||||||
|
3. Convex functions access the user via `ctx.auth.getUserIdentity()`.
|
||||||
|
4. `convex/model/users.ts` exports `getCurrentUser`, `requireAdmin`, `requireOwnership`.
|
||||||
|
5. Clerk webhooks sync user changes to the `users` table via `convex/http.ts`.
|
||||||
|
|
||||||
|
### Guest Cart & Session
|
||||||
|
|
||||||
|
- A guest `sessionId` is generated on first load and stored in a cookie (`apps/storefront/src/lib/session/`).
|
||||||
|
- All cart/wishlist queries accept either `userId` (signed-in) or `sessionId` (guest).
|
||||||
|
- On sign-in, `SessionCartMerge` component triggers a merge mutation to fold the guest cart into the user's cart.
|
||||||
|
|
||||||
|
### Checkout Flow
|
||||||
|
|
||||||
|
1. Address validation — `convex/model/checkout.ts` + Convex address functions
|
||||||
|
2. Shipping rates — Shippo API via `convex/model/shippo.ts`
|
||||||
|
3. Payment intent — Stripe via `convex/stripeActions.ts`
|
||||||
|
4. Order creation — snapshots of items, addresses, and pricing captured in `convex/orders.ts`
|
||||||
|
5. Stripe webhook — handled in `convex/http.ts`, updates order payment status
|
||||||
|
|
||||||
|
## Convex Function Conventions
|
||||||
|
|
||||||
|
Always use the new function syntax with explicit validators:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query, mutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
export const myFunction = query({
|
||||||
|
args: { id: v.id("products") },
|
||||||
|
handler: async (ctx, args) => { ... },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use `internalQuery` / `internalMutation` / `internalAction` for private functions.
|
||||||
|
- HTTP endpoints go in `convex/http.ts` using `httpAction`.
|
||||||
|
- Use `ctx.runQuery` / `ctx.runMutation` from actions; avoid chaining many calls (race conditions).
|
||||||
|
- Leverage index-based queries over full table scans.
|
||||||
|
|
||||||
|
## Storefront UI Rules
|
||||||
|
|
||||||
|
**HeroUI** is the component library for the storefront. Two mandatory overrides:
|
||||||
|
|
||||||
|
| Situation | Use |
|
||||||
|
|-----------|-----|
|
||||||
|
| Internal navigation | `next/link` `<Link>` (never `@heroui/react` `Link`) |
|
||||||
|
| Content images | `next/image` `<Image>` (never raw `<img>`) |
|
||||||
|
|
||||||
|
Preserve semantic HTML (`<main>`, `<header>`, `<footer>`, `<article>` per product card, `<ol>` for breadcrumbs) even when composing Hero UI components around them.
|
||||||
|
|
||||||
|
## PetPaws Branding (Storefront)
|
||||||
|
|
||||||
|
**Typography:**
|
||||||
|
- Headings (h1–h3): Fraunces serif — `font-[family-name:var(--font-fraunces)]`
|
||||||
|
- Body / UI: DM Sans — `font-sans` (default)
|
||||||
|
|
||||||
|
**Key colors:**
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|-------|-----|-------|
|
||||||
|
| Deep Teal | `#236f6b` | Header/footer bg, price text |
|
||||||
|
| Brand Teal | `#38a99f` | Primary buttons, links, focus rings |
|
||||||
|
| Sunny Amber | `#f4a13a` | High-priority CTAs ("Add to Cart") |
|
||||||
|
| Playful Coral | `#f2705a` | Discount badges, sale text, alerts |
|
||||||
|
| Forest Black | `#1a2e2d` | Body text (never pure `#000000`) |
|
||||||
|
| Ice White | `#f0f8f7` | Page background |
|
||||||
|
|
||||||
|
Prefer CSS variables or Tailwind arbitrary values over raw hex. Do not mix coral and amber in the same component.
|
||||||
|
|
||||||
|
## Shared Packages
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Product, Order, User } from "@repo/types";
|
||||||
|
import { formatPrice, slugify, formatDate } from "@repo/utils";
|
||||||
|
import { ConvexClientProvider } from "@repo/convex"; // wraps app layouts
|
||||||
|
```
|
||||||
|
|
||||||
|
Both apps' `next.config.js` transpile `@repo/*` packages.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests are co-located with source files (`convex/*.test.ts`). Vitest runs in edge-runtime using `convex-test`. Environment variables for tests are not required — `convex-test` mocks the Convex runtime.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Each app has its own `.env.local` (copy from `.env.example`). Required vars:
|
||||||
|
- `NEXT_PUBLIC_CONVEX_URL` — from Convex dashboard
|
||||||
|
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` / `CLERK_SECRET_KEY`
|
||||||
|
- `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
||||||
|
- `SHIPPO_API_KEY`
|
||||||
|
|
||||||
|
Convex backend env vars (set in Convex Dashboard): `CLERK_JWT_ISSUER_DOMAIN`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `SHIPPO_API_KEY`.
|
||||||
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# The Pet Loft — Ecommerce Monorepo
|
||||||
|
|
||||||
|
A full-stack ecommerce platform for pet supplies built with Next.js, Convex,
|
||||||
|
Clerk, and Turborepo.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ecommerce/
|
||||||
|
├── apps/
|
||||||
|
│ ├── storefront/ # Customer-facing store (Next.js + HeroUI) — port 3000
|
||||||
|
│ └── admin/ # Admin dashboard (Next.js + ShadCN) — port 3001
|
||||||
|
├── convex/ # Convex backend (schema, functions, tests)
|
||||||
|
├── packages/
|
||||||
|
│ ├── types/ # Shared TypeScript types (Product, Order, User...)
|
||||||
|
│ ├── utils/ # Shared helper functions (formatPrice, slugify...)
|
||||||
|
│ └── convex/ # Shared Convex client provider (@repo/convex)
|
||||||
|
├── docs/ # Convex reference docs + project documentation
|
||||||
|
├── package.json # Root workspace config
|
||||||
|
└── turbo.json # Turborepo pipeline config
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed documentation, see [`docs/project-documentation/`](./docs/project-documentation/).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### 1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Set up Convex
|
||||||
|
|
||||||
|
1. Go to [convex.dev](https://convex.dev) and create a new project
|
||||||
|
2. Run `npx convex dev` from the root — this will prompt you to log in and
|
||||||
|
link your project
|
||||||
|
|
||||||
|
### 3. Set up Clerk
|
||||||
|
|
||||||
|
1. Go to [clerk.com](https://clerk.com) and create a new application
|
||||||
|
2. In the Clerk Dashboard, create a JWT Template named `convex` (do **not**
|
||||||
|
rename it)
|
||||||
|
3. Copy your Publishable Key, Secret Key, and the JWT Issuer URL
|
||||||
|
4. Set `CLERK_JWT_ISSUER_DOMAIN` in the Convex Dashboard environment variables
|
||||||
|
|
||||||
|
### 4. Configure environment variables
|
||||||
|
|
||||||
|
Copy the example env files and fill in your credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp apps/storefront/.env.example apps/storefront/.env.local
|
||||||
|
cp apps/admin/.env.example apps/admin/.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run the development servers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — Convex backend
|
||||||
|
npx convex dev
|
||||||
|
|
||||||
|
# Terminal 2 — Both Next.js apps
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Storefront** → http://localhost:3000
|
||||||
|
- **Admin** → http://localhost:3001
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
### `@repo/types`
|
||||||
|
All shared TypeScript types used across both apps and the Convex backend.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Product, Order, User } from "@repo/types";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `@repo/utils`
|
||||||
|
Shared utility functions.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { formatPrice, slugify, formatDate } from "@repo/utils";
|
||||||
|
|
||||||
|
formatPrice(1999) // → "$19.99"
|
||||||
|
slugify("Hello World!") // → "hello-world"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `@repo/convex`
|
||||||
|
Shared Convex + Clerk provider. Both apps wrap their layout with this.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ConvexClientProvider } from "@repo/convex";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
This project uses **Clerk** for authentication and **Convex** for the backend.
|
||||||
|
|
||||||
|
- Clerk handles sign-in/sign-up UI and JWT tokens
|
||||||
|
- `ConvexProviderWithClerk` passes the JWT to the Convex backend
|
||||||
|
- Convex functions access the user identity via `ctx.auth.getUserIdentity()`
|
||||||
|
- Admin-only functions check the `role` field via `requireAdmin()`
|
||||||
|
- Clerk webhooks sync user changes to the Convex `users` table
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | What it does |
|
||||||
|
|--------|-------------|
|
||||||
|
| `npm run dev` | Run both apps in parallel via Turbo |
|
||||||
|
| `npm run dev:storefront` | Run only the storefront |
|
||||||
|
| `npm run dev:admin` | Run only the admin |
|
||||||
|
| `npm run build` | Build both apps |
|
||||||
|
| `npm run type-check` | TypeScript check across all workspaces |
|
||||||
|
| `npm run test:once` | Run all tests once |
|
||||||
|
| `npm run test` | Run tests in watch mode |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
| App | Recommended Platform |
|
||||||
|
|-----|---------------------|
|
||||||
|
| Storefront | Vercel (root dir: `apps/storefront`) |
|
||||||
|
| Admin | Vercel (root dir: `apps/admin`) |
|
||||||
|
| Backend | Convex Cloud (`npx convex deploy`) |
|
||||||
3
apps/storefront/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
CLERK_SECRET_KEY=sk_test_...
|
||||||
6
apps/storefront/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
8
apps/storefront/next.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
||||||
|
// PPR: enable when using Next.js canary. Uncomment and add experimental_ppr to PDP page:
|
||||||
|
// experimental: { ppr: "incremental" },
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
23
apps/storefront/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "storefront",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@clerk/nextjs": "^6.38.2",
|
||||||
|
"@heroui/react": "^3.0.0-beta.7",
|
||||||
|
"@heroui/styles": "^3.0.0-beta.7",
|
||||||
|
"@repo/convex": "*",
|
||||||
|
"@repo/types": "*",
|
||||||
|
"@repo/utils": "*",
|
||||||
|
"@stripe/react-stripe-js": "^5.6.0",
|
||||||
|
"@stripe/stripe-js": "^8.8.0",
|
||||||
|
"framer-motion": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
571
apps/storefront/pet-palette.html
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PetPaws — Brand Color Palette</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400;600;700&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand: #38a99f;
|
||||||
|
--brand-dark: #236f6b;
|
||||||
|
--brand-light: #8dd5d1;
|
||||||
|
--brand-mist: #e8f7f6;
|
||||||
|
|
||||||
|
--warm: #f4a13a;
|
||||||
|
--warm-light: #fde8c8;
|
||||||
|
|
||||||
|
--coral: #f2705a;
|
||||||
|
--coral-light: #fce0da;
|
||||||
|
|
||||||
|
--neutral-900: #1a2e2d;
|
||||||
|
--neutral-700: #3d5554;
|
||||||
|
--neutral-400: #8aa9a8;
|
||||||
|
--neutral-100: #f0f8f7;
|
||||||
|
--white: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--neutral-100);
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
color: var(--neutral-900);
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: .35rem .9rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--neutral-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
margin-top: .6rem;
|
||||||
|
color: var(--neutral-700);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── section label ── */
|
||||||
|
.section-label {
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--neutral-400);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── swatch grid ── */
|
||||||
|
.palette-group {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(56,169,159,.08);
|
||||||
|
background: white;
|
||||||
|
transition: transform .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
.swatch:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(56,169,159,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-color {
|
||||||
|
height: 110px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-color .star {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px; right: 10px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-info {
|
||||||
|
padding: .75rem 1rem .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: .9rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
color: var(--neutral-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-hex {
|
||||||
|
font-size: .78rem;
|
||||||
|
color: var(--neutral-400);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-role {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--neutral-700);
|
||||||
|
margin-top: .3rem;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── usage card ── */
|
||||||
|
.usage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem 1.4rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(56,169,159,.07);
|
||||||
|
border-left: 4px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-card h3 {
|
||||||
|
font-size: .88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-card p {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--neutral-700);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── preview strip ── */
|
||||||
|
.preview {
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 24px rgba(56,169,159,.15);
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-nav {
|
||||||
|
background: var(--brand-dark);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-nav .logo {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-nav .logo span { color: var(--warm); }
|
||||||
|
|
||||||
|
.preview-nav nav a {
|
||||||
|
color: rgba(255,255,255,.75);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .82rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hero {
|
||||||
|
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 60%, var(--brand-light) 100%);
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hero-text { flex: 1; min-width: 200px; }
|
||||||
|
|
||||||
|
.preview-hero h2 {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-size: clamp(1.6rem, 4vw, 2.4rem);
|
||||||
|
color: white;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: .8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-hero p {
|
||||||
|
color: rgba(255,255,255,.82);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
max-width: 320px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--warm);
|
||||||
|
color: var(--neutral-900);
|
||||||
|
border: none;
|
||||||
|
padding: .7rem 1.5rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: .88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
border: 1.5px solid rgba(255,255,255,.5);
|
||||||
|
padding: .68rem 1.4rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: .88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-cards {
|
||||||
|
background: var(--neutral-100);
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 140px;
|
||||||
|
box-shadow: 0 2px 8px rgba(56,169,159,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-card-color {
|
||||||
|
height: 70px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-card h4 {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: .2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-card .price {
|
||||||
|
color: var(--brand-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-sale {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--coral-light);
|
||||||
|
color: var(--coral);
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: .1rem .4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-new {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--warm-light);
|
||||||
|
color: #b36a10;
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: .1rem .4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* typography combos */
|
||||||
|
.typo-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.4rem 1.6rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(56,169,159,.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typo-card .label {
|
||||||
|
font-size: .7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
color: var(--neutral-400);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: .78rem;
|
||||||
|
color: var(--neutral-400);
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--brand-mist);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="badge">🐾 Brand Color System</div>
|
||||||
|
<h1>PetPaws — Color Palette</h1>
|
||||||
|
<p>Designed around client primary <strong>#38a99f</strong> · Pet Items E-commerce</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- PRIMARY PALETTE -->
|
||||||
|
<div class="palette-group">
|
||||||
|
<p class="section-label">01 · Primary — Teal Brand Family</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#236f6b;"><span class="star">★</span></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Deep Teal</div>
|
||||||
|
<div class="swatch-hex">#236f6b</div>
|
||||||
|
<div class="swatch-role">Headers, Nav, Footer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#38a99f;"><span class="star" style="color:white">★ LOGO</span></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Brand Teal</div>
|
||||||
|
<div class="swatch-hex">#38a99f</div>
|
||||||
|
<div class="swatch-role">Logo, Primary Buttons, Links</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#8dd5d1;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Soft Teal</div>
|
||||||
|
<div class="swatch-hex">#8dd5d1</div>
|
||||||
|
<div class="swatch-role">Hover States, Icons, Tags</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#e8f7f6;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Teal Mist</div>
|
||||||
|
<div class="swatch-hex">#e8f7f6</div>
|
||||||
|
<div class="swatch-role">Backgrounds, Cards, Section Fills</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACCENT PALETTE -->
|
||||||
|
<div class="palette-group">
|
||||||
|
<p class="section-label">02 · Accent — Warmth & Energy</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#f4a13a;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Sunny Amber</div>
|
||||||
|
<div class="swatch-hex">#f4a13a</div>
|
||||||
|
<div class="swatch-role">CTA Buttons, Sale Banners, Highlights</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#fde8c8;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Amber Cream</div>
|
||||||
|
<div class="swatch-hex">#fde8c8</div>
|
||||||
|
<div class="swatch-role">"New" Tags, Promotional Chips</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#f2705a;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Playful Coral</div>
|
||||||
|
<div class="swatch-hex">#f2705a</div>
|
||||||
|
<div class="swatch-role">Alerts, Discount Badges, Wishlist</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#fce0da;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Coral Blush</div>
|
||||||
|
<div class="swatch-hex">#fce0da</div>
|
||||||
|
<div class="swatch-role">"Sale" Tag Fills, Error Backgrounds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NEUTRAL PALETTE -->
|
||||||
|
<div class="palette-group">
|
||||||
|
<p class="section-label">03 · Neutrals — Structure & Readability</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#1a2e2d;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Forest Black</div>
|
||||||
|
<div class="swatch-hex">#1a2e2d</div>
|
||||||
|
<div class="swatch-role">Body Text, Dark Mode BG</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#3d5554;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Moss Grey</div>
|
||||||
|
<div class="swatch-hex">#3d5554</div>
|
||||||
|
<div class="swatch-role">Subheadings, Secondary Text</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#8aa9a8;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Sage Mist</div>
|
||||||
|
<div class="swatch-hex">#8aa9a8</div>
|
||||||
|
<div class="swatch-role">Placeholders, Borders, Labels</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="swatch">
|
||||||
|
<div class="swatch-color" style="background:#f0f8f7; border: 1px solid #daeeed;"></div>
|
||||||
|
<div class="swatch-info">
|
||||||
|
<div class="swatch-name">Ice White</div>
|
||||||
|
<div class="swatch-hex">#f0f8f7</div>
|
||||||
|
<div class="swatch-role">Page Background, Card BG</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USAGE GUIDE -->
|
||||||
|
<p class="section-label">04 · Usage Guide</p>
|
||||||
|
<div class="usage-grid">
|
||||||
|
<div class="usage-card" style="border-color:var(--brand);">
|
||||||
|
<h3>🖥 Navigation & Header</h3>
|
||||||
|
<p>Deep Teal <strong>#236f6b</strong> background with white text. Logo in Brand Teal on light pages.</p>
|
||||||
|
</div>
|
||||||
|
<div class="usage-card" style="border-color:var(--warm);">
|
||||||
|
<h3>🛒 Primary CTA Buttons</h3>
|
||||||
|
<p>Sunny Amber <strong>#f4a13a</strong> — "Add to Cart", "Shop Now". Creates contrast & urgency.</p>
|
||||||
|
</div>
|
||||||
|
<div class="usage-card" style="border-color:var(--coral);">
|
||||||
|
<h3>🏷 Sale & Discount Badges</h3>
|
||||||
|
<p>Coral <strong>#f2705a</strong> on Coral Blush background. High visibility without harsh red.</p>
|
||||||
|
</div>
|
||||||
|
<div class="usage-card" style="border-color:var(--brand-light);">
|
||||||
|
<h3>📦 Product Cards</h3>
|
||||||
|
<p>White card on Ice White <strong>#f0f8f7</strong> background. Teal border on hover.</p>
|
||||||
|
</div>
|
||||||
|
<div class="usage-card" style="border-color:var(--neutral-700);">
|
||||||
|
<h3>📝 Typography</h3>
|
||||||
|
<p>Forest Black <strong>#1a2e2d</strong> for body. Deep Teal for headings. Sage Mist for metadata.</p>
|
||||||
|
</div>
|
||||||
|
<div class="usage-card" style="border-color:var(--brand-mist);">
|
||||||
|
<h3>🐶 Category Sections</h3>
|
||||||
|
<p>Alternating Teal Mist <strong>#e8f7f6</strong> and White sections for rhythm and visual flow.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LIVE PREVIEW -->
|
||||||
|
<p class="section-label">05 · Live UI Preview</p>
|
||||||
|
<div class="preview">
|
||||||
|
<div class="preview-nav">
|
||||||
|
<div class="logo">Pet<span>Paws</span> 🐾</div>
|
||||||
|
<nav>
|
||||||
|
<a href="#">Dogs</a>
|
||||||
|
<a href="#">Cats</a>
|
||||||
|
<a href="#">Birds</a>
|
||||||
|
<a href="#">Sale</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="preview-hero">
|
||||||
|
<div class="preview-hero-text">
|
||||||
|
<h2>Everything Your Pet Deserves</h2>
|
||||||
|
<p>Premium food, toys & accessories — curated with love for your furry family.</p>
|
||||||
|
<button class="btn-primary">Shop Now</button>
|
||||||
|
<button class="btn-outline">Browse Categories</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-cards">
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-card-color" style="background:var(--brand-mist);">🦴</div>
|
||||||
|
<div class="tag-new">NEW</div>
|
||||||
|
<h4>Dog Treats</h4>
|
||||||
|
<div class="price">KES 850</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-card-color" style="background:var(--warm-light);">🐱</div>
|
||||||
|
<div class="tag-sale">SALE 20%</div>
|
||||||
|
<h4>Cat Toy Set</h4>
|
||||||
|
<div class="price">KES 640</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-card-color" style="background:var(--coral-light);">🐦</div>
|
||||||
|
<h4>Bird Cage</h4>
|
||||||
|
<div class="price">KES 3,200</div>
|
||||||
|
</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="mini-card-color" style="background:var(--brand-mist);">🐟</div>
|
||||||
|
<div class="tag-new">NEW</div>
|
||||||
|
<h4>Fish Food</h4>
|
||||||
|
<div class="price">KES 420</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TYPOGRAPHY -->
|
||||||
|
<p class="section-label">06 · Recommended Font Pairing</p>
|
||||||
|
<div class="typo-row">
|
||||||
|
<div class="typo-card">
|
||||||
|
<div class="label">Display / Headings</div>
|
||||||
|
<div style="font-family:'Fraunces',serif;font-size:2rem;font-weight:700;color:var(--neutral-900);line-height:1.1;">Fraunces<br><span style="color:var(--brand);">Serif</span></div>
|
||||||
|
<p style="font-size:.78rem;color:var(--neutral-400);margin-top:.5rem;">Warm, playful, trustworthy. Perfect for hero text and product names.</p>
|
||||||
|
</div>
|
||||||
|
<div class="typo-card">
|
||||||
|
<div class="label">Body / UI Text</div>
|
||||||
|
<div style="font-family:'DM Sans',sans-serif;font-size:1.6rem;font-weight:400;color:var(--neutral-700);">DM Sans<br><span style="font-weight:300;color:var(--neutral-400);">Clean Sans-Serif</span></div>
|
||||||
|
<p style="font-size:.78rem;color:var(--neutral-400);margin-top:.5rem;">Legible, modern, friendly. Great for body copy, buttons, and labels.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
PetPaws Brand System · Primary #38a99f · Generated for Pet Items E-Commerce
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
apps/storefront/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
111
apps/storefront/public/branding/logo.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 60 KiB |
BIN
apps/storefront/public/content/news-letter.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
apps/storefront/public/content/newsletter-dog.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 1023 KiB |
|
After Width: | Height: | Size: 950 KiB |
|
After Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
80
apps/storefront/public/icons/icon_bowl.svg
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
61
apps/storefront/public/icons/icon_carrier.svg
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
66
apps/storefront/public/icons/icon_food.svg
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="Layer_1"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
width="119.14328"
|
||||||
|
viewBox="0 0 119.14328 131.41817"
|
||||||
|
enable-background="new 0 0 672 672"
|
||||||
|
xml:space="preserve"
|
||||||
|
height="131.41817"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs9" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
id="g10"
|
||||||
|
transform="matrix(0.30533736,0,0,0.30533736,-39.259692,-39.009023)"><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 298,127.83081 c 45.98755,0.0183 91.47513,0.0419 136.96268,0.0485 10.00793,10e-4 17.77722,3.84984 22.41003,13.01848 0.81397,1.61088 1.39908,3.39775 1.74307,5.17064 0.44245,2.2805 1.04425,4.70779 0.69916,6.93582 -3.41745,22.06328 7.39343,39.92412 16.81107,58.26765 7.75509,15.1052 15.08471,30.42861 22.81833,45.54511 7.07074,13.8208 5.74768,28.88009 6.66247,43.55893 1.88382,30.22836 3.32584,60.48422 4.94544,90.72903 0.66738,12.46289 1.2362,24.93179 2.00286,37.38855 1.89057,30.71942 4.17298,61.41837 5.68995,92.15561 0.80346,16.28088 -12.22415,37.4942 -34.20188,37.49664 -55.65133,0.006 -111.30267,0.022 -166.95401,0.0104 -50.65249,-0.0106 -101.30513,-0.12854 -151.95744,-0.0434 -20.69795,0.0348 -32.42798,-12.84533 -36.36882,-29.92126 -1.81802,-7.87762 0.53474,-15.91467 0.74936,-23.89053 0.27286,-10.13959 1.11081,-20.26398 1.70855,-30.39472 1.96015,-33.22134 3.99811,-66.43829 5.85563,-99.66537 1.19876,-21.44351 2.10073,-42.90345 3.19978,-64.35273 0.63885,-12.46792 1.00458,-24.96899 2.23592,-37.38147 1.03665,-10.45013 6.40795,-19.53123 11.07371,-28.73317 10.38435,-20.48028 20.70203,-40.98909 30.3252,-61.84585 2.88578,-6.25449 1.54374,-12.76656 2.19661,-19.14735 0.37132,-3.62906 0.0804,-7.3247 0.10928,-10.99019 0.11186,-14.17713 8.9711,-23.45314 23.31117,-23.85402 11.32176,-0.31649 22.65839,-0.11603 33.9884,-0.1201 17.82784,-0.006 35.65566,0.008 53.98348,0.0147 m 201.87561,327.58444 c 0.44351,-4.41314 -1.66129,-8.50015 -1.7175,-12.82135 -0.37921,-29.14251 -2.5964,-58.1907 -4.26492,-87.26971 -1.3064,-22.76761 -2.08621,-45.565 -3.24628,-68.34171 -0.49213,-9.66168 -1.56634,-19.19403 -6.10159,-28.07705 -10.22009,-20.01769 -20.1658,-40.17527 -30.28909,-60.24266 -4.35279,-8.62859 -9.18827,-17.07457 -9.36319,-27.12148 -0.10151,-5.83023 -0.11442,-11.66264 -0.10828,-17.494 0.01,-9.27091 -1.76322,-11.07251 -11.06366,-11.07343 -38.98434,-0.004 -77.96872,-0.0121 -116.95306,-0.004 -34.98853,0.007 -69.97713,-6.1e-4 -104.96552,0.077 -8.40929,0.0186 -10.43547,2.5921 -9.94975,11.00359 0.3163,5.47782 0.27418,10.99671 0.0247,16.48048 -0.20267,4.455 0.50554,8.63795 2.42395,12.59845 7.40423,15.28586 14.93388,30.51124 22.28538,45.82228 6.68988,13.93314 15.44614,27.12241 18.25275,42.55817 2.85971,15.72787 2.25554,31.83285 3.2753,47.76434 1.32918,20.76538 2.13981,41.56351 3.2731,62.3421 1.53206,28.09006 3.16328,56.17465 4.74289,84.26209 0.82223,14.62079 1.41241,29.25857 2.50604,43.85882 0.82578,11.02436 1.88736,21.94818 -4.21225,32.01215 -0.0928,0.15308 0.2256,0.55542 0.51846,1.21771 15.98066,0 32.11805,-0.0209 48.25534,0.004 60.30384,0.0922 120.60767,0.20411 180.9115,0.29694 11.4712,0.0176 19.82953,-8.91547 19.16703,-20.16626 -0.66437,-11.2829 -1.07843,-22.58035 -1.64051,-33.86948 -0.5462,-10.97021 -1.1409,-21.93798 -1.76083,-33.81671 M 243.8648,522.35754 c -0.17048,-21.64813 -2.17367,-43.20419 -3.30675,-64.80337 -1.7348,-33.06971 -3.81713,-66.1211 -5.58953,-99.18894 -1.25617,-23.43658 -2.06366,-46.89822 -3.45417,-70.32596 -0.45894,-7.73245 -0.45758,-15.56302 -3.70761,-23.02679 -9.17917,-21.08026 -20.19385,-41.24988 -30.21979,-61.90732 -0.74652,-1.53815 -1.32417,-3.81802 -3.65615,-3.54707 -2.09015,0.24287 -2.60357,2.38209 -3.41611,3.97474 -8.92136,17.48655 -17.78627,35.00191 -26.7312,52.47638 -4.16336,8.1334 -6.38132,16.65308 -6.7535,25.84668 -0.80096,19.78614 -2.01562,39.55567 -3.07982,59.331 -1.55587,28.91214 -3.04525,57.82821 -4.7241,86.73327 -1.04166,17.93454 -2.6668,35.83857 -3.49489,53.78101 -0.63666,13.79489 -1.60983,27.56485 -2.2953,41.34857 -0.50154,10.08484 10.5307,20.93701 19.97004,20.45435 20.30732,-1.03827 40.62699,-0.16352 60.93847,-0.5683 12.16493,-0.24237 19.3961,-7.30432 19.52041,-20.57825 z"
|
||||||
|
id="path1-1" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 325.99969,374.19998 c 2.16602,-3.22934 4.4094,-5.86008 7.31519,-7.92538 4.9744,-3.53559 8.71939,-8.06601 11.33481,-13.64914 9.53238,-20.34864 35.17157,-22.25653 46.96396,-3.31098 4.7933,7.7009 10.23361,14.52774 17.02774,20.60163 6.74121,6.02658 9.32175,14.22699 6.93854,23.18826 -2.68427,10.09341 -9.19257,16.89609 -19.51566,19.31735 -5.17328,1.21335 -10.45898,0.0215 -14.99838,-2.44739 -8.22357,-4.47259 -15.86014,-4.06234 -24.14289,0.0802 -16.64517,8.32493 -32.89895,-4.68133 -35.04095,-20.30734 -0.75125,-5.48041 1.6308,-10.40359 4.11764,-15.54718 z"
|
||||||
|
id="path2-2" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 282.99988,171.77496 c 40.31048,0.0262 80.12097,0.0436 119.93136,0.10521 2.48578,0.004 5.05292,0.0274 7.43702,0.62291 3.43005,0.85681 5.4923,3.57572 5.30981,6.96932 -0.19165,3.56363 -2.2984,6.25589 -6.25684,6.66163 -1.81662,0.18622 -3.66003,0.14875 -5.4913,0.14913 -54.13611,0.0113 -108.27222,0.0186 -162.40834,0.0147 -7.68434,-5.5e-4 -11.50755,-2.55257 -11.33183,-7.47739 0.16832,-4.71748 3.72238,-6.964 11.33362,-6.9931 13.65869,-0.0522 27.31762,-0.0378 41.4765,-0.0524 z"
|
||||||
|
id="path3-7" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 362.32965,312.89392 c 0.54916,3.22055 0.59515,6.00754 -0.081,8.81131 -1.29111,5.35373 -5.75302,9.72864 -10.78815,10.48151 -5.49243,0.82129 -11.08313,-1.90702 -13.6871,-7.08506 -2.77466,-5.51742 -3.59699,-11.36715 -1.75244,-17.39697 1.65243,-5.40192 5.16448,-9.05142 10.8493,-9.677 5.42755,-0.59729 9.62659,1.9328 12.47339,6.60938 1.47745,2.42712 2.74478,4.93707 2.98599,8.25683 z"
|
||||||
|
id="path4-0" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 401.5097,306.84048 c 2.59714,6.87482 2.0954,13.08915 -1.73895,18.95716 -3.45556,5.28839 -8.41028,7.57922 -13.49432,6.25689 -6.89643,-1.79376 -10.22177,-5.70883 -10.5546,-12.70809 -0.19696,-4.14124 -0.38888,-8.23945 1.49567,-12.23978 2.56473,-5.44422 6.38409,-8.91452 12.5347,-9.19989 6.10403,-0.28324 9.34814,3.52276 11.7575,8.93371 z"
|
||||||
|
id="path5-9" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 321.5708,328.86133 c 7.47806,1.96579 10.56815,7.1958 11.64209,13.88672 0.85287,5.31366 -0.61307,9.87341 -5.16693,13.18866 -2.91248,2.12033 -5.93445,2.63183 -9.42035,1.53741 -9.26938,-2.91019 -13.84183,-16.62598 -8.05579,-24.45972 2.65323,-3.59222 6.32212,-4.65887 11.00098,-4.15307 z"
|
||||||
|
id="path6-3" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 427.71466,349.41199 c -5.7294,9.51263 -14.25357,11.48297 -19.86313,4.71621 -3.84915,-4.64322 -4.07672,-10.10699 -1.94745,-15.49707 2.06082,-5.21676 5.83405,-8.87924 11.62891,-9.74945 5.47153,-0.82159 9.74487,2.10593 11.22153,7.51212 1.17712,4.30948 0.72571,8.54132 -1.03986,13.01819 z"
|
||||||
|
id="path7-6" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 201.31534,396.99963 c 0.0118,33.82151 0.0191,67.1431 0.0202,100.46466 4e-5,1.33216 -0.0188,2.6666 -0.0969,3.99601 -0.2249,3.82892 -2.16414,6.30447 -6.00829,6.83383 -3.92772,0.54083 -6.35605,-1.72315 -7.57536,-5.12689 -0.65294,-1.82273 -0.74137,-3.9198 -0.74286,-5.89331 -0.0489,-64.81064 -0.0614,-129.62128 -0.0551,-194.43195 3.2e-4,-3.31625 -0.36163,-6.68555 0.79697,-9.90906 1.14177,-3.17676 3.14969,-5.95028 6.65118,-5.87408 3.74747,0.0815 5.77998,2.99576 6.56293,6.5177 0.35626,1.6026 0.37503,3.30243 0.37688,4.95837 0.0366,32.65491 0.0481,65.30982 0.0703,98.46472 z"
|
||||||
|
id="path8-0" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 7.6 KiB |
36
apps/storefront/public/icons/icon_litter.svg
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
30
apps/storefront/public/icons/icon_toys.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
46
apps/storefront/public/icons/icon_treats.svg
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="Layer_1"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
width="117.33741"
|
||||||
|
viewBox="0 0 117.33741 119.65257"
|
||||||
|
enable-background="new 0 0 672 672"
|
||||||
|
xml:space="preserve"
|
||||||
|
height="119.65257"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs9" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<g
|
||||||
|
id="g12"
|
||||||
|
transform="matrix(0.79095488,0,0,0.79095488,-38.072758,-41.928473)"><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 144.14966,203.35962 c -11.20554,-5.06966 -15.78997,-13.6742 -13.81761,-25.55219 0.68418,-4.12034 -1.29447,-7.12321 -3.88311,-9.78566 C 115.07063,156.319 103.60671,144.69943 92.173012,133.05051 90.772758,131.6239 89.216141,130.32062 87.991936,128.75671 83.447472,122.95122 77.9851,120.46199 70.247513,121.0553 59.069221,121.91246 49.87006,113.0538 48.332176,101.41795 46.84869,90.193657 53.861641,79.834671 65.395103,77.409142 c 4.44719,-0.935265 6.005837,-2.492402 6.84105,-7.041428 2.051323,-11.17252 12.975273,-18.728569 23.447853,-17.149296 11.979684,1.806553 20.328124,12.105404 18.866214,24.140316 -0.76049,6.260727 1.06368,10.83458 5.44127,15.181358 12.88259,12.791868 25.54249,25.809188 38.21267,38.813168 4.18955,4.29992 8.63458,6.50767 15.07245,5.63742 10.37816,-1.4029 20.7359,7.07589 22.78113,17.6284 2.21402,11.4234 -4.37663,22.83697 -15.48192,25.63684 -4.64764,1.17177 -6.88744,2.58627 -7.95947,7.87975 -2.49674,12.3283 -14.97177,18.65519 -28.46669,15.22395 M 108.52586,79.751335 c 0.17073,-3.313164 0.66446,-6.624237 -0.58729,-9.876968 -2.87086,-7.460037 -9.813379,-11.708649 -17.323084,-10.51524 -7.543106,1.198715 -13.108192,7.824459 -13.304726,15.840488 -0.178756,7.291489 -0.178756,7.291489 -7.215637,7.203621 -7.072578,-0.08831 -10.996051,3.395661 -11.118591,9.873085 -0.195778,10.349079 6.083984,16.744469 16.728111,16.704739 6.137429,-0.0229 11.476944,1.60206 15.834603,6.05806 13.624054,13.93149 27.180894,27.93019 40.934724,41.73249 5.15669,5.17486 7.60759,10.90629 7.10084,18.346 -0.43415,6.37424 2.45295,11.7717 8.09308,15.06591 5.24286,3.06215 10.64217,1.94969 15.7518,-0.67325 2.46883,-1.26734 4.06693,-3.27248 4.32909,-6.26943 0.61209,-6.99763 0.67454,-6.99218 7.7458,-7.60096 8.95345,-0.77083 16.1115,-9.52301 15.13524,-18.50588 -0.88824,-8.17282 -9.35995,-15.92265 -16.91475,-14.46052 -8.27456,1.60144 -14.21913,-1.59835 -19.62736,-7.10628 -13.07373,-13.31477 -26.10248,-26.67452 -39.27059,-39.895401 -4.22946,-4.246422 -6.78296,-8.993286 -6.29126,-15.920464 z"
|
||||||
|
id="path1-9" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 98.062119,111.68851 c -0.152947,-3.60406 1.419845,-5.76871 4.405901,-6.71484 2.3823,-0.75482 4.63081,-0.13998 6.25625,1.83771 1.57589,1.9174 2.01563,4.11732 0.96341,6.47562 -1.04995,2.35319 -2.96438,3.59983 -5.44925,3.60466 -3.19039,0.006 -5.184536,-1.79558 -6.176311,-5.20315 z"
|
||||||
|
id="path2-20" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 116.55191,131.23082 c -1.24698,-4.2846 0.34399,-6.85281 4.04014,-8.01667 3.11951,-0.98228 5.53564,0.37035 6.94149,3.20184 1.55984,3.14161 0.64398,5.97091 -2.19243,7.73995 -3.60841,2.25052 -6.58835,1.0768 -8.7892,-2.92512 z"
|
||||||
|
id="path3-2" /><path
|
||||||
|
fill="#000000"
|
||||||
|
opacity="1"
|
||||||
|
stroke="none"
|
||||||
|
d="m 135.74579,151.32532 c -2.4355,-4.64391 -1.79547,-7.95854 1.69559,-9.51732 3.39224,-1.51464 6.28433,-0.62625 8.04557,2.65672 1.8248,3.40144 0.52428,6.18431 -2.55323,8.05007 -2.40172,1.45603 -4.79523,0.86216 -7.18793,-1.18947 z"
|
||||||
|
id="path4-37" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
BIN
apps/storefront/public/images/cta/cta-01.webp
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/storefront/public/images/cta/cta-011.webp
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/storefront/public/images/cta/cta-02.webp
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/storefront/public/images/cta/cta-03.webp
Normal file
|
After Width: | Height: | Size: 148 KiB |
18
apps/storefront/src/app/account/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "My Account",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="w-full max-w-full px-4 py-8 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/storefront/src/app/account/orders/[orderId]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OrderDetailPageView } from "@/components/orders/OrderDetailPageView";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ orderId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Order Details",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OrderDetailPage({ params }: Props) {
|
||||||
|
const { orderId } = await params;
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<OrderDetailPageView orderId={orderId} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/storefront/src/app/account/orders/loading.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Skeleton } from "@heroui/react";
|
||||||
|
import { OrdersSkeleton } from "@/components/orders/state/OrdersSkeleton";
|
||||||
|
|
||||||
|
export default function OrdersLoading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page heading skeleton */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-40 rounded-md" />
|
||||||
|
<Skeleton className="h-4 w-56 rounded-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrdersSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/storefront/src/app/account/orders/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { SectionHeading } from "@/utils/common/heading/section_heading";
|
||||||
|
import { OrdersPageView } from "@/components/orders/OrdersPageView";
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<SectionHeading title="My Orders" as="h1" />
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
View and manage your order history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrdersPageView />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/storefront/src/app/account/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useConvexAuth, useQuery } from "convex/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "../../../../../convex/_generated/api";
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title: "Order History",
|
||||||
|
description: "View your past orders and track current ones.",
|
||||||
|
href: "/account/orders",
|
||||||
|
cta: "View orders",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Wishlist",
|
||||||
|
description: "View and manage your saved items.",
|
||||||
|
href: "/wishlist",
|
||||||
|
cta: "Coming soon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Addresses",
|
||||||
|
description: "Manage your shipping and billing addresses.",
|
||||||
|
href: "/account/addresses",
|
||||||
|
cta: "Coming soon",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Profile Settings",
|
||||||
|
description: "Update your name, email, and preferences.",
|
||||||
|
href: "/account/settings",
|
||||||
|
cta: "Coming soon",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const { isAuthenticated, isLoading: authLoading } = useConvexAuth();
|
||||||
|
const user = useQuery(
|
||||||
|
api.users.current,
|
||||||
|
isAuthenticated ? {} : "skip",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (authLoading || (isAuthenticated && user === undefined)) {
|
||||||
|
return <AccountSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || user === null) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<p className="text-gray-500">You need to sign in to view your account.</p>
|
||||||
|
<Link
|
||||||
|
href="/sign-in"
|
||||||
|
className="mt-4 inline-block rounded-md bg-[#236f6b] px-6 py-2 text-sm font-medium text-white hover:bg-[#1b5955] transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif text-2xl font-semibold text-gray-900 md:text-3xl">
|
||||||
|
My Account
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Welcome back, {user.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||||
|
<h2 className="text-sm font-medium uppercase tracking-wide text-gray-500">
|
||||||
|
Account Details
|
||||||
|
</h2>
|
||||||
|
<dl className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Name</dt>
|
||||||
|
<dd className="mt-1 text-sm font-medium text-gray-900">{user.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Email</dt>
|
||||||
|
<dd className="mt-1 text-sm font-medium text-gray-900">{user.email}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Role</dt>
|
||||||
|
<dd className="mt-1 text-sm font-medium text-gray-900 capitalize">{user.role}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Link
|
||||||
|
key={section.href}
|
||||||
|
href={section.href}
|
||||||
|
className="group rounded-lg border border-gray-200 bg-white p-6 transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-gray-900 group-hover:text-[#236f6b]">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{section.description}</p>
|
||||||
|
<span className="mt-3 inline-block text-sm font-medium text-[#236f6b]">
|
||||||
|
{section.cta} →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-8">
|
||||||
|
<div>
|
||||||
|
<div className="h-8 w-40 rounded bg-gray-200" />
|
||||||
|
<div className="mt-2 h-4 w-56 rounded bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||||
|
<div className="h-4 w-32 rounded bg-gray-200" />
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-3 w-16 rounded bg-gray-200" />
|
||||||
|
<div className="mt-2 h-4 w-36 rounded bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||||
|
<div className="h-5 w-28 rounded bg-gray-200" />
|
||||||
|
<div className="mt-2 h-4 w-full rounded bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/storefront/src/app/cart/CartPageView.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CartPageLayout } from "@/components/cart/containers/CartPageLayout";
|
||||||
|
import { CartContent } from "@/components/cart/content/CartContent";
|
||||||
|
import { CartContentSkeleton } from "@/components/cart/state/CartContentSkeleton";
|
||||||
|
import { CartErrorState } from "@/components/cart/state/CartErrorState";
|
||||||
|
import { useCart } from "@/lib/cart/useCart";
|
||||||
|
import { useCartMutations } from "@/lib/cart/useCartMutations";
|
||||||
|
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart page: wires Convex data and mutations to shared CartContent.
|
||||||
|
* Shows skeleton while loading, error state on failure, otherwise CartContent with real data.
|
||||||
|
*/
|
||||||
|
export function CartPageView() {
|
||||||
|
const sessionId = useCartSessionId();
|
||||||
|
const { items, subtotal, isLoading, error } = useCart(sessionId);
|
||||||
|
const { updateItem, removeItem } = useCartMutations(sessionId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<CartPageLayout itemCount={0}>
|
||||||
|
<CartContentSkeleton />
|
||||||
|
</CartPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<CartPageLayout itemCount={0}>
|
||||||
|
<CartErrorState
|
||||||
|
message={error.message ?? "Something went wrong loading your cart."}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
</CartPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CartPageLayout itemCount={itemCount}>
|
||||||
|
<CartContent
|
||||||
|
items={items}
|
||||||
|
subtotal={subtotal}
|
||||||
|
onUpdateQuantity={updateItem}
|
||||||
|
onRemove={removeItem}
|
||||||
|
isEmpty={items.length === 0}
|
||||||
|
onViewFullCart={undefined}
|
||||||
|
hideHeading
|
||||||
|
layout="page"
|
||||||
|
/>
|
||||||
|
</CartPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/storefront/src/app/cart/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart layout: noindex/nofollow and title (Phase 6 rules compliance).
|
||||||
|
* See docs/development/ui-implementation-rules/07-cart-checkout.md.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Cart",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CartLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
13
apps/storefront/src/app/cart/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { CartPageLayout } from "@/components/cart/containers/CartPageLayout";
|
||||||
|
import { CartContentSkeleton } from "@/components/cart/state/CartContentSkeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart page loading state: heading + card-based skeleton.
|
||||||
|
*/
|
||||||
|
export default function CartLoading() {
|
||||||
|
return (
|
||||||
|
<CartPageLayout>
|
||||||
|
<CartContentSkeleton />
|
||||||
|
</CartPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/storefront/src/app/cart/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { CartPageView } from "./CartPageView";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cart page: full-page cart with shared content.
|
||||||
|
* Meta (noindex, title) in layout.tsx. Loading state in loading.tsx.
|
||||||
|
* Visible h1 and item count come from CartPageLayout + CartContent.
|
||||||
|
*/
|
||||||
|
export default function CartPage() {
|
||||||
|
return <CartPageView />;
|
||||||
|
}
|
||||||
106
apps/storefront/src/app/checkout/CheckoutPageView.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
|
||||||
|
import { useCartValidation, useShippingAddresses } from "@/lib/checkout";
|
||||||
|
import type { CheckoutStep } from "@/lib/checkout/constants";
|
||||||
|
import type { CheckoutSelectedShippingRate } from "@/lib/checkout/types";
|
||||||
|
import { CheckoutShell } from "@/components/checkout/CheckoutShell";
|
||||||
|
import { CartValidationStep } from "@/components/checkout/steps/CartValidationStep";
|
||||||
|
import { ShippingAddressStep } from "@/components/checkout/steps/ShippingAddressStep";
|
||||||
|
import { OrderReviewStep } from "@/components/checkout/steps/OrderReviewStep";
|
||||||
|
import { PaymentStep } from "@/components/checkout/steps/PaymentStep";
|
||||||
|
import { CheckoutSkeleton } from "@/components/checkout/state/CheckoutSkeleton";
|
||||||
|
import { CheckoutErrorState } from "@/components/checkout/state/CheckoutErrorState";
|
||||||
|
|
||||||
|
export function CheckoutPageView() {
|
||||||
|
const sessionId = useCartSessionId();
|
||||||
|
const { result, isLoading } = useCartValidation(sessionId);
|
||||||
|
const { addresses, isLoading: isLoadingAddresses } = useShippingAddresses();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState<CheckoutStep>("validation");
|
||||||
|
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
|
||||||
|
const [shipmentObjectId, setShipmentObjectId] = useState<string | null>(null);
|
||||||
|
const [selectedShippingRate, setSelectedShippingRate] = useState<CheckoutSelectedShippingRate | null>(null);
|
||||||
|
|
||||||
|
const isEmpty = !isLoading && result !== null && result.items.length === 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEmpty) router.replace("/cart");
|
||||||
|
}, [isEmpty, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<CheckoutShell currentStep="validation">
|
||||||
|
<CheckoutSkeleton />
|
||||||
|
</CheckoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<CheckoutShell currentStep="validation">
|
||||||
|
<CheckoutErrorState
|
||||||
|
message="Something went wrong loading your checkout."
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
</CheckoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CheckoutShell currentStep={currentStep}>
|
||||||
|
{currentStep === "validation" && (
|
||||||
|
<CartValidationStep
|
||||||
|
result={result}
|
||||||
|
onProceed={() => setCurrentStep("shipping-address")}
|
||||||
|
onBackToCart={() => router.push("/cart")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "shipping-address" && (
|
||||||
|
<ShippingAddressStep
|
||||||
|
addresses={addresses}
|
||||||
|
isLoadingAddresses={isLoadingAddresses}
|
||||||
|
onProceed={(addressId) => {
|
||||||
|
setSelectedAddressId(addressId);
|
||||||
|
setCurrentStep("review");
|
||||||
|
}}
|
||||||
|
onBack={() => setCurrentStep("validation")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "review" && selectedAddressId && result && (
|
||||||
|
<OrderReviewStep
|
||||||
|
addressId={selectedAddressId}
|
||||||
|
addresses={addresses}
|
||||||
|
cartResult={result}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onProceed={(shipmentId, shippingRate) => {
|
||||||
|
setShipmentObjectId(shipmentId);
|
||||||
|
setSelectedShippingRate(shippingRate);
|
||||||
|
setCurrentStep("payment");
|
||||||
|
}}
|
||||||
|
onBack={() => setCurrentStep("shipping-address")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "payment" &&
|
||||||
|
selectedAddressId &&
|
||||||
|
shipmentObjectId &&
|
||||||
|
selectedShippingRate && (
|
||||||
|
<PaymentStep
|
||||||
|
shipmentObjectId={shipmentObjectId}
|
||||||
|
selectedShippingRate={selectedShippingRate}
|
||||||
|
addressId={selectedAddressId}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onBack={() => setCurrentStep("review")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CheckoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/storefront/src/app/checkout/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Checkout",
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CheckoutLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="w-full max-w-full px-4 py-6 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/storefront/src/app/checkout/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CheckoutSkeleton } from "@/components/checkout/state/CheckoutSkeleton";
|
||||||
|
|
||||||
|
export default function CheckoutLoading() {
|
||||||
|
return <CheckoutSkeleton />;
|
||||||
|
}
|
||||||
7
apps/storefront/src/app/checkout/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckoutPageView } from "./CheckoutPageView";
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
return <CheckoutPageView />;
|
||||||
|
}
|
||||||
253
apps/storefront/src/app/checkout/success/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useAction } from "convex/react";
|
||||||
|
import { Button, Card, Link, Spinner } from "@heroui/react";
|
||||||
|
import { api } from "../../../../../../convex/_generated/api";
|
||||||
|
|
||||||
|
type PageState =
|
||||||
|
| { phase: "loading" }
|
||||||
|
| { phase: "complete"; email: string | null }
|
||||||
|
| { phase: "incomplete" }
|
||||||
|
| { phase: "error"; message: string };
|
||||||
|
|
||||||
|
// ─── Main Content (inside Suspense) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function SuccessContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const sessionId = searchParams.get("session_id");
|
||||||
|
const getStatus = useAction(api.stripeActions.getCheckoutSessionStatus);
|
||||||
|
const [state, setState] = useState<PageState>({ phase: "loading" });
|
||||||
|
const fetchRef = useRef(0);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setState({
|
||||||
|
phase: "error",
|
||||||
|
message:
|
||||||
|
"No payment session found. If you completed a payment, please check your email for confirmation.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ++fetchRef.current;
|
||||||
|
setState({ phase: "loading" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getStatus({ sessionId });
|
||||||
|
if (id !== fetchRef.current) return;
|
||||||
|
|
||||||
|
if (result.status === "complete") {
|
||||||
|
setState({ phase: "complete", email: result.customerEmail });
|
||||||
|
} else {
|
||||||
|
setState({ phase: "incomplete" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (id !== fetchRef.current) return;
|
||||||
|
setState({
|
||||||
|
phase: "error",
|
||||||
|
message:
|
||||||
|
"Unable to retrieve payment status. Please check your email for confirmation or contact support.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [sessionId, getStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
if (state.phase === "loading") {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Card className="w-full max-w-md rounded-xl p-6 md:p-8">
|
||||||
|
<Card.Content className="flex flex-col items-center gap-5 p-0 text-center">
|
||||||
|
{state.phase === "complete" && (
|
||||||
|
<CompleteView email={state.email} />
|
||||||
|
)}
|
||||||
|
{state.phase === "incomplete" && <IncompleteView />}
|
||||||
|
{state.phase === "error" && <ErrorView message={state.message} />}
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State Views ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CompleteView({ email }: { email: string | null }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-50">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-8 text-[#236f6b]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
|
||||||
|
Payment successful!
|
||||||
|
</h1>
|
||||||
|
{email && (
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
A confirmation has been sent to{" "}
|
||||||
|
<span className="font-medium text-foreground">{email}</span>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
Your order has been placed and you'll receive a confirmation
|
||||||
|
email shortly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href="/account/orders"
|
||||||
|
color="primary"
|
||||||
|
className="w-full bg-[#236f6b] font-medium text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
View your orders
|
||||||
|
</Button>
|
||||||
|
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
||||||
|
Continue shopping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncompleteView() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-amber-50">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-8 text-amber-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
|
||||||
|
Payment was not completed
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-default-500">
|
||||||
|
Your payment was not completed. No charges have been made. You can
|
||||||
|
return to checkout to try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href="/checkout"
|
||||||
|
color="primary"
|
||||||
|
className="w-full bg-[#236f6b] font-medium text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Return to checkout
|
||||||
|
</Button>
|
||||||
|
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
||||||
|
Continue shopping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorView({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-full bg-red-50">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-8 text-danger"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-default-500">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href="/checkout"
|
||||||
|
color="primary"
|
||||||
|
className="w-full bg-[#236f6b] font-medium text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
Return to checkout
|
||||||
|
</Button>
|
||||||
|
<Button as={Link} href="/" variant="ghost" className="w-full">
|
||||||
|
Go to homepage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[60vh] flex-col items-center justify-center gap-4"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<Spinner size="lg" />
|
||||||
|
<p className="text-sm text-default-500">Verifying your payment…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page Export ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CheckoutSuccessPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingState />}>
|
||||||
|
<SuccessContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
apps/storefront/src/app/globals.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@heroui/styles";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The Pet Loft Brand Theme — mapped to HeroUI CSS variables
|
||||||
|
*
|
||||||
|
* Primary Teal: #38a99f (Brand) · #236f6b (Deep) · #8dd5d1 (Soft) · #e8f7f6 (Mist)
|
||||||
|
* Accent Warm: #f4a13a (Amber) · #fde8c8 (Cream)
|
||||||
|
* Accent Coral: #f2705a (Coral) · #fce0da (Blush)
|
||||||
|
* Neutrals: #1a2e2d (Forest) · #3d5554 (Moss) · #8aa9a8 (Sage) · #f0f8f7 (Ice)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root,
|
||||||
|
.light,
|
||||||
|
.default,
|
||||||
|
[data-theme="light"],
|
||||||
|
[data-theme="default"] {
|
||||||
|
/* The Pet Loft brand palette — beyond HeroUI variables */
|
||||||
|
--brand: #38a99f;
|
||||||
|
--brand-dark: #236f6b;
|
||||||
|
--brand-light: #8dd5d1;
|
||||||
|
--brand-mist: #e8f7f6;
|
||||||
|
--warm: #f4a13a;
|
||||||
|
--warm-light: #fde8c8;
|
||||||
|
--coral: #f2705a;
|
||||||
|
--coral-light: #fce0da;
|
||||||
|
--neutral-900: #1a2e2d;
|
||||||
|
--neutral-700: #3d5554;
|
||||||
|
--neutral-400: #8aa9a8;
|
||||||
|
--neutral-100: #f0f8f7;
|
||||||
|
|
||||||
|
/* Brand Teal #38a99f — primary actions, links, focus */
|
||||||
|
--accent: oklch(66.96% 0.1009 186.67);
|
||||||
|
--accent-foreground: oklch(100.00% 0 0);
|
||||||
|
|
||||||
|
/* Ice White #f0f8f7 — page background */
|
||||||
|
--background: oklch(97.28% 0.0086 188.66);
|
||||||
|
|
||||||
|
/* Sage Mist #8aa9a8 — borders */
|
||||||
|
--border: oklch(71.13% 0.0341 194.09);
|
||||||
|
|
||||||
|
--danger: oklch(65.32% 0.2335 17.37);
|
||||||
|
--danger-foreground: oklch(99.11% 0 0);
|
||||||
|
|
||||||
|
/* Teal Mist #e8f7f6 — default/neutral chip & tag fills */
|
||||||
|
--default: oklch(96.47% 0.0159 192.37);
|
||||||
|
--default-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
/* White #ffffff — input backgrounds */
|
||||||
|
--field-background: oklch(100.00% 0.0001 263.28);
|
||||||
|
--field-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
/* Sage Mist #8aa9a8 — placeholders */
|
||||||
|
--field-placeholder: oklch(71.13% 0.0341 194.09);
|
||||||
|
|
||||||
|
/* Brand Teal — focus rings */
|
||||||
|
--focus: oklch(66.96% 0.1009 186.67);
|
||||||
|
|
||||||
|
/* Forest Black #1a2e2d — body text */
|
||||||
|
--foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
/* Moss Grey #3d5554 — muted/secondary text */
|
||||||
|
--muted: oklch(42.97% 0.0292 192.97);
|
||||||
|
|
||||||
|
/* White — overlays */
|
||||||
|
--overlay: oklch(100.00% 0.0001 263.28);
|
||||||
|
--overlay-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
--scrollbar: oklch(71.13% 0.0341 194.09);
|
||||||
|
|
||||||
|
/* White — segmented controls */
|
||||||
|
--segment: oklch(100.00% 0.0001 263.28);
|
||||||
|
--segment-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
--separator: oklch(96.47% 0.0159 192.37);
|
||||||
|
|
||||||
|
--success: oklch(73.29% 0.1941 142.44);
|
||||||
|
--success-foreground: oklch(21.03% 0.0059 142.44);
|
||||||
|
|
||||||
|
/* White — card surfaces */
|
||||||
|
--surface: oklch(100.00% 0.0001 263.28);
|
||||||
|
--surface-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
/* Teal Mist #e8f7f6 — secondary surfaces */
|
||||||
|
--surface-secondary: oklch(96.47% 0.0159 192.37);
|
||||||
|
--surface-secondary-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
/* Ice White #f0f8f7 — tertiary surfaces */
|
||||||
|
--surface-tertiary: oklch(97.28% 0.0086 188.66);
|
||||||
|
--surface-tertiary-foreground: oklch(28.38% 0.0260 191.82);
|
||||||
|
|
||||||
|
--warning: oklch(78.19% 0.1590 63.96);
|
||||||
|
--warning-foreground: oklch(21.03% 0.0059 63.96);
|
||||||
|
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--field-radius: 0.75rem;
|
||||||
|
|
||||||
|
/* Fonts: DM Sans (body) + Fraunces (headings) */
|
||||||
|
--font-sans: var(--font-dm-sans);
|
||||||
|
--font-serif: var(--font-fraunces);
|
||||||
|
|
||||||
|
/* Design system: 8px grid (spatial system) */
|
||||||
|
--spacing-1: 0.25rem; /* 4px — tight */
|
||||||
|
--spacing-2: 0.5rem; /* 8px — base unit */
|
||||||
|
--spacing-3: 0.75rem; /* 12px — compact */
|
||||||
|
--spacing-4: 1rem; /* 16px — standard */
|
||||||
|
--spacing-6: 1.5rem; /* 24px — comfortable */
|
||||||
|
--spacing-8: 2rem; /* 32px — generous */
|
||||||
|
--spacing-12: 3rem; /* 48px — section spacing */
|
||||||
|
--spacing-16: 4rem; /* 64px — large sections */
|
||||||
|
|
||||||
|
/* Design system: transition speeds */
|
||||||
|
--transition-fast: 150ms; /* color changes, small UI updates */
|
||||||
|
--transition-base: 250ms; /* standard interactions */
|
||||||
|
--transition-slow: 350ms; /* complex animations */
|
||||||
|
--transition-bounce: 400ms; /* playful hover states */
|
||||||
|
--transition-spring: 600ms; /* entrances/exits */
|
||||||
|
|
||||||
|
/* Design system: breakpoints (use in media queries; values in px) */
|
||||||
|
--breakpoint-sm: 640px;
|
||||||
|
--breakpoint-md: 768px;
|
||||||
|
--breakpoint-lg: 1024px;
|
||||||
|
--breakpoint-xl: 1280px;
|
||||||
|
--breakpoint-2xl: 1536px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Base layer — PetPaws branding + Design System (DESIGN_SYSTEM_DOCS.md, petpaws-branding).
|
||||||
|
* Body: DM Sans (--font-sans), Forest Black text (--foreground), Ice White page bg (--background).
|
||||||
|
* Borders: Sage Mist (--border). Headings use Fraunces via .font-serif / font-[family-name:var(--font-serif)].
|
||||||
|
* Smooth scroll; 8px grid spacing via --spacing-*; transitions via --transition-*.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Design system: carousel / marquee (use duration with var(--transition-spring) or var(--transition-base)) */
|
||||||
|
@keyframes scroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Continuous right-to-left marquee for utility bar */
|
||||||
|
.animate-marquee {
|
||||||
|
animation: scroll 25s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playful micro-animation (design system: bouncy hover feel) */
|
||||||
|
@keyframes sparkle {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: translateY(-10px) scale(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for carousel / horizontal scroll areas */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
58
apps/storefront/src/app/layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { DM_Sans, Fraunces } from "next/font/google";
|
||||||
|
import { ClerkProvider } from "@clerk/nextjs";
|
||||||
|
import { ConvexClientProvider } from "@repo/convex";
|
||||||
|
import { CartUIProvider } from "../components/cart/CartUIProvider";
|
||||||
|
import { Header } from "../components/layout/header/Header";
|
||||||
|
import { SessionCartMerge } from "../lib/session/SessionCartMerge";
|
||||||
|
import { StoreUserSync } from "../lib/session/StoreUserSync";
|
||||||
|
import { Footer } from "../components/layout/footer/Footer";
|
||||||
|
import { ToastProvider } from "../components/layout/ToastProvider";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const dmSans = DM_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500"],
|
||||||
|
variable: "--font-dm-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fraunces = Fraunces({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "600", "700"],
|
||||||
|
variable: "--font-fraunces",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: "%s | The Pet Loft",
|
||||||
|
default: "The Pet Loft — Pet Supplies & More",
|
||||||
|
},
|
||||||
|
description: "Your one-stop shop for premium pet supplies",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${dmSans.variable} ${fraunces.variable} font-sans flex min-h-screen max-w-full flex-col overflow-x-hidden`}
|
||||||
|
>
|
||||||
|
<ClerkProvider>
|
||||||
|
<ConvexClientProvider>
|
||||||
|
<SessionCartMerge />
|
||||||
|
<StoreUserSync />
|
||||||
|
<CartUIProvider>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
<ToastProvider />
|
||||||
|
</CartUIProvider>
|
||||||
|
</ConvexClientProvider>
|
||||||
|
</ClerkProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/storefront/src/app/not-found.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { SectionHeading } from "../utils/common/heading/section_heading";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
|
||||||
|
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
|
||||||
|
<SectionHeading title="Page Not Found" as="h1" className="text-3xl md:text-4xl" />
|
||||||
|
|
||||||
|
<p className="mt-4 max-w-md text-base leading-relaxed text-[var(--muted)] md:text-lg">
|
||||||
|
Sorry, we couldn't find the page you're looking for. It
|
||||||
|
may have been moved, renamed, or no longer exists.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-8 inline-flex items-center rounded-full bg-[#f4a13a] px-8 py-3 text-sm font-medium text-[#1a2e2d] transition-opacity hover:opacity-90 md:w-auto"
|
||||||
|
>
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
|
||||||
|
<Image
|
||||||
|
src="/content/illustrations/404_not_found.svg"
|
||||||
|
alt="Page not found illustration"
|
||||||
|
width={500}
|
||||||
|
height={500}
|
||||||
|
priority
|
||||||
|
className="h-auto w-full"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="https://storyset.com/online"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-[var(--muted)] hover:underline"
|
||||||
|
>
|
||||||
|
Online illustrations by Storyset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/storefront/src/app/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CtaSection } from "../components/sections/hompepage/cta/CtaSection";
|
||||||
|
import { CategorySection } from "../components/sections/hompepage/category/CategorySection";
|
||||||
|
import { NewsletterSection } from "../components/sections/hompepage/newsletter/NewsletterSection";
|
||||||
|
import { RecentlyAddedSection } from "../components/sections/hompepage/products-sections/recently-added/RecentlyAddedSection";
|
||||||
|
import { SpecialOffersSection } from "../components/sections/hompepage/products-sections/special-offers/SpecialOffersSection";
|
||||||
|
import { TopPicksSection } from "../components/sections/hompepage/products-sections/top-picks/TopPicsSection";
|
||||||
|
import { WishlistSection } from "../components/sections/hompepage/wishlist/WishlistSection";
|
||||||
|
import { Toast } from "@heroui/react";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen min-w-0 max-w-full overflow-x-hidden bg-background px-4 py-8 text-foreground md:px-6">
|
||||||
|
<Toast.Provider placement="top" className="bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm" />
|
||||||
|
|
||||||
|
<CtaSection />
|
||||||
|
<CategorySection />
|
||||||
|
<WishlistSection />
|
||||||
|
<RecentlyAddedSection />
|
||||||
|
<SpecialOffersSection />
|
||||||
|
<TopPicksSection />
|
||||||
|
<NewsletterSection />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Page-level loading fallback for /shop/[category]/[subCategory]/[slug].
|
||||||
|
* Mirrors the new PDP layout: hero grid + tab navigation + content skeleton.
|
||||||
|
* Uses Tailwind only so this stays a Server Component (HeroUI Skeleton is client-only).
|
||||||
|
*/
|
||||||
|
export default function ProductDetailLoading() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-7xl px-4 py-6 md:py-8">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="mb-6 h-4 w-2/3 max-w-sm animate-pulse rounded bg-[var(--muted)] md:mb-8" />
|
||||||
|
|
||||||
|
{/* Hero grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
|
||||||
|
{/* Gallery */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="aspect-square w-full animate-pulse rounded-xl bg-[var(--muted)] md:aspect-[4/3]" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-16 w-16 shrink-0 animate-pulse rounded-lg bg-[var(--muted)]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buy box */}
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--muted)]" />
|
||||||
|
<div className="h-8 w-full max-w-sm animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
<div className="h-7 w-28 animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--muted)]" />
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<div className="h-10 w-full animate-pulse rounded-lg bg-[var(--muted)] md:w-36" />
|
||||||
|
<div className="h-12 w-full animate-pulse rounded-lg bg-[var(--muted)] md:w-44" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab navigation + first section skeleton */}
|
||||||
|
<div className="mt-8 md:mt-12">
|
||||||
|
<div className="flex gap-4 border-b border-[var(--muted)]">
|
||||||
|
<div className="h-10 w-28 animate-pulse rounded-t bg-[var(--muted)]" />
|
||||||
|
<div className="h-10 w-20 animate-pulse rounded-t bg-[var(--muted)]" />
|
||||||
|
<div className="h-10 w-24 animate-pulse rounded-t bg-[var(--muted)]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 py-6 md:py-8">
|
||||||
|
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
<div className="h-4 w-full max-w-2xl animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
<div className="h-4 w-full max-w-2xl animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
<div className="h-4 w-3/4 max-w-xl animate-pulse rounded bg-[var(--muted)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product-specific 404 when slug is invalid or product not found / not active
|
||||||
|
* at /shop/[category]/[subCategory]/[slug].
|
||||||
|
*/
|
||||||
|
export default function ProductNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-16 text-center">
|
||||||
|
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[var(--foreground)] md:text-3xl">
|
||||||
|
Product not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-[var(--muted)]">
|
||||||
|
This product doesn't exist or is no longer available. Try browsing
|
||||||
|
the shop.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/shop"
|
||||||
|
className="mt-6 inline-block rounded-lg bg-[var(--brand-dark)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--brand-hover)] w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Back to Shop
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { fetchQuery } from "convex/nextjs";
|
||||||
|
import { api } from "../../../../../../../../convex/_generated/api";
|
||||||
|
import { ProductDetailContent } from "@/components/product-detail/ProductDetailContent";
|
||||||
|
import type { ProductDetailParams } from "@/lib/product-detail/constants";
|
||||||
|
import { getProductDetailPath } from "@/lib/product-detail/constants";
|
||||||
|
import { isPetCategorySlug } from "@/lib/shop/constants";
|
||||||
|
import { ProductDetailStructuredData } from "@/components/product-detail/ProductDetailStructuredData";
|
||||||
|
|
||||||
|
// PPR: when using Next.js canary, uncomment so PDP uses Partial Prerendering:
|
||||||
|
// export const experimental_ppr = true;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<ProductDetailParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidSlug(slug: string): boolean {
|
||||||
|
return typeof slug === "string" && slug.length > 0 && !slug.includes("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip HTML tags for plain-text meta description. */
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meta description: 150–160 chars per SEO rule. */
|
||||||
|
function metaDescription(
|
||||||
|
seoDescription: string | undefined | null,
|
||||||
|
rawDescription: string | undefined | null,
|
||||||
|
): string | undefined {
|
||||||
|
if (seoDescription && seoDescription.trim()) {
|
||||||
|
return seoDescription.slice(0, 160);
|
||||||
|
}
|
||||||
|
if (rawDescription && rawDescription.trim()) {
|
||||||
|
return stripHtml(rawDescription).slice(0, 160);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { category, subCategory, slug } = await params;
|
||||||
|
if (!isPetCategorySlug(category) || !isValidSlug(slug)) {
|
||||||
|
return { title: "Not Found" };
|
||||||
|
}
|
||||||
|
const resolved = await fetchQuery(api.categories.getByPath, {
|
||||||
|
categorySlug: category,
|
||||||
|
subCategorySlug: subCategory,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return { title: "Not Found" };
|
||||||
|
}
|
||||||
|
const product = await fetchQuery(api.products.getBySlug, { slug });
|
||||||
|
if (!product) {
|
||||||
|
return { title: "Not Found" };
|
||||||
|
}
|
||||||
|
const title = product.seoTitle ?? product.name;
|
||||||
|
const description = metaDescription(
|
||||||
|
product.seoDescription,
|
||||||
|
product.description,
|
||||||
|
);
|
||||||
|
const canonicalPath = getProductDetailPath(category, subCategory, slug);
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL ?? process.env.NEXT_PUBLIC_STOREFRONT_URL ?? "";
|
||||||
|
const canonical = baseUrl ? `${baseUrl.replace(/\/$/, "")}${canonicalPath}` : undefined;
|
||||||
|
const firstImage =
|
||||||
|
product.images && product.images.length > 0 ? product.images[0] : null;
|
||||||
|
const openGraphImages =
|
||||||
|
firstImage?.url != null
|
||||||
|
? [{ url: firstImage.url, alt: firstImage.alt ?? product.name }]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: canonical ? { canonical } : undefined,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description: description ?? undefined,
|
||||||
|
url: canonical,
|
||||||
|
images: openGraphImages,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title,
|
||||||
|
description: description ?? undefined,
|
||||||
|
images: firstImage?.url != null ? [firstImage.url] : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductDetailPage({ params }: Props) {
|
||||||
|
const { category, subCategory, slug } = await params;
|
||||||
|
|
||||||
|
if (!isPetCategorySlug(category)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await fetchQuery(api.categories.getByPath, {
|
||||||
|
categorySlug: category,
|
||||||
|
subCategorySlug: subCategory,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSlug(slug)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await fetchQuery(api.products.getBySlug, { slug });
|
||||||
|
if (!product) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProductDetailStructuredData
|
||||||
|
category={category}
|
||||||
|
subCategory={subCategory}
|
||||||
|
slug={slug}
|
||||||
|
productName={product.name}
|
||||||
|
subCategoryName={resolved.name}
|
||||||
|
/>
|
||||||
|
<ProductDetailContent
|
||||||
|
category={category}
|
||||||
|
subCategory={subCategory}
|
||||||
|
slug={slug}
|
||||||
|
categoryId={product.categoryId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||