Compare commits
35 Commits
9cee6b0671
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5199b82d | |||
| 8585ddf92a | |||
| 7521e98ff3 | |||
| 56d7a653eb | |||
| 0cb2c00f43 | |||
| c8f5d8d096 | |||
| f1dbf0b6ee | |||
| 777c3b34bc | |||
| f6156c78d1 | |||
| 0bd0d90f45 | |||
| 3396a79445 | |||
| 9f2e9afc63 | |||
| 64c0cd6af8 | |||
| af8e14c545 | |||
| 9ede637f39 | |||
| 0da06b965d | |||
| 439d6d4455 | |||
| b333047753 | |||
| 0b9ac5cd46 | |||
| bfc20ac293 | |||
| 33fed9382a | |||
| 5b0a727bce | |||
| 5391b3b428 | |||
| 829fec9ac1 | |||
| 6b63cbb6cd | |||
| bc7306fea4 | |||
| 7a6da4f18f | |||
| fc5f98541b | |||
| 70b728a474 | |||
| 79640074cd | |||
| c1ab930e48 | |||
| 9013905d01 | |||
| 23efcab80c | |||
| 2f5537cf7e | |||
| 51663df27d |
24
.gitea/OPEN_PR_TO_STAGING.md
Normal file
24
.gitea/OPEN_PR_TO_STAGING.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Open Pull Request: feat/storefront → staging
|
||||
|
||||
## 1. Push your branch (if needed)
|
||||
```bash
|
||||
git push gitea feat/storefront
|
||||
```
|
||||
|
||||
## 2. Create the pull request in Gitea
|
||||
|
||||
Open this URL in your browser (replace `GITEA_BASE_URL` with your Gitea UI base, e.g. `https://72.61.144.167:3000` or your actual Gitea domain):
|
||||
|
||||
```
|
||||
GITEA_BASE_URL/admin/the-pet-loft/compare/staging...feat/storefront
|
||||
```
|
||||
|
||||
Or in Gitea UI:
|
||||
1. Go to **admin/the-pet-loft**
|
||||
2. Click **Pull requests** → **New pull request**
|
||||
3. Set **Base** = `staging`, **Head** = `feat/storefront`
|
||||
4. Use the pre-filled description from `.gitea/PULL_REQUEST_TEMPLATE.md` if needed
|
||||
5. Create the pull request
|
||||
|
||||
## 3. After CI passes
|
||||
Merge the PR into `staging` from the Gitea PR page.
|
||||
14
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
14
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,14 @@
|
||||
## Summary
|
||||
Merge `feat/storefront` into `staging` to bring storefront and support/legal updates onto the staging branch.
|
||||
|
||||
## Changes (feat/storefront → staging)
|
||||
- **Returns & refunds**: Support returns page, FAQ, and General Terms updated so customer bears return postage cost (deducted from refund).
|
||||
- **Storefront**: Returns policy page content aligned with legal (14-day withdrawal, return address, exclusions).
|
||||
- **CI**: Branch pattern in workflow updated.
|
||||
|
||||
## Checklist
|
||||
- [ ] CI (lint, typecheck, test) passes on `feat/storefront`
|
||||
- [ ] No merge conflicts with `staging`
|
||||
|
||||
## How to merge
|
||||
After approval, merge this PR into `staging` (merge commit or squash as per your workflow).
|
||||
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**" # TODO: change to "**" after testing
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Lint, Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test
|
||||
run: npm run test:once
|
||||
258
.gitea/workflows/deploy-staging.yml
Normal file
258
.gitea/workflows/deploy-staging.yml
Normal file
@@ -0,0 +1,258 @@
|
||||
name: Deploy — Staging
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- staging
|
||||
|
||||
# Gitea Actions has no environment-level secrets (unlike GitHub Actions).
|
||||
# Staging and production secrets live at repo level, distinguished by prefix.
|
||||
# Production workflow uses the same names with PROD_ prefix.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets and Variables → Actions):
|
||||
# STAGING_REGISTRY — host:port/owner
|
||||
# STAGING_REGISTRY_USER — Gitea username
|
||||
# STAGING_REGISTRY_TOKEN — Gitea PAT (package:write)
|
||||
# STAGING_SSH_HOST — host.containers.internal
|
||||
# STAGING_SSH_USER — SSH user on the VPS
|
||||
# STAGING_SSH_KEY — SSH private key (full PEM)
|
||||
# STAGING_SSH_PORT — (optional) defaults to 22
|
||||
# STAGING_NEXT_PUBLIC_CONVEX_URL
|
||||
# STAGING_STOREFRONT_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
# STAGING_ADMIN_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
# STAGING_NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME
|
||||
# STAGING_NEXT_PUBLIC_CLOUDINARY_API_KEY
|
||||
# STAGING_NEXT_PUBLIC_IMAGE_PROCESSING_API_URL
|
||||
# STAGING_NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
# STAGING_STOREFRONT_CLERK_SECRET_KEY
|
||||
# STAGING_ADMIN_CLERK_SECRET_KEY
|
||||
# STAGING_CLOUDINARY_API_SECRET
|
||||
|
||||
jobs:
|
||||
# ── 0. Detect changes ────────────────────────────────────────────────────────
|
||||
# Determines which apps need to be rebuilt on this push.
|
||||
# Shared paths (packages/, convex/, root config) mark both apps as changed.
|
||||
|
||||
changes:
|
||||
name: Detect changed apps
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
storefront: ${{ steps.detect.outputs.storefront }}
|
||||
admin: ${{ steps.detect.outputs.admin }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Determine affected apps
|
||||
id: detect
|
||||
run: |
|
||||
BASE=$(git rev-parse HEAD~1 2>/dev/null || git hash-object -t tree /dev/null)
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD)
|
||||
|
||||
STOREFRONT=false
|
||||
ADMIN=false
|
||||
|
||||
# Shared paths affect both apps
|
||||
if echo "$CHANGED" | grep -qE '^(package\.json|package-lock\.json|turbo\.json|packages/|convex/)'; then
|
||||
STOREFRONT=true
|
||||
ADMIN=true
|
||||
fi
|
||||
|
||||
echo "$CHANGED" | grep -q '^apps/storefront/' && STOREFRONT=true || true
|
||||
echo "$CHANGED" | grep -q '^apps/admin/' && ADMIN=true || true
|
||||
|
||||
echo "storefront=${STOREFRONT}" >> "$GITHUB_OUTPUT"
|
||||
echo "admin=${ADMIN}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── 1. CI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
ci:
|
||||
name: Lint, Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run type-check
|
||||
- run: npm run test:once
|
||||
|
||||
# ── 2a. Build storefront ─────────────────────────────────────────────────────
|
||||
|
||||
build-storefront:
|
||||
name: Build & push — storefront
|
||||
needs: [ci, changes]
|
||||
if: needs.changes.outputs.storefront == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Prune workspace
|
||||
run: |
|
||||
npx turbo prune storefront --docker
|
||||
cp -r convex out/full/convex
|
||||
cp package-lock.json out/package-lock.json
|
||||
|
||||
- name: Authenticate with registry
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${{ secrets.STAGING_REGISTRY_USER }}:${{ secrets.STAGING_REGISTRY_TOKEN }}" | base64 -w 0)
|
||||
REGISTRY_HOST=$(echo "${{ secrets.STAGING_REGISTRY }}" | cut -d'/' -f1)
|
||||
echo "{\"auths\":{\"${REGISTRY_HOST}\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Build & push
|
||||
env:
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.STAGING_STOREFRONT_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
||||
NEXT_PUBLIC_CONVEX_URL: ${{ secrets.STAGING_NEXT_PUBLIC_CONVEX_URL }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${{ secrets.STAGING_NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
||||
run: |
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
IMAGE="${{ secrets.STAGING_REGISTRY }}/storefront"
|
||||
docker build \
|
||||
-f apps/storefront/Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" \
|
||||
--build-arg NEXT_PUBLIC_CONVEX_URL="$NEXT_PUBLIC_CONVEX_URL" \
|
||||
--build-arg NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY" \
|
||||
--load -t "${IMAGE}:staging" ./out
|
||||
docker tag "${IMAGE}:staging" "${IMAGE}:sha-${SHORT_SHA}"
|
||||
docker push "${IMAGE}:staging"
|
||||
docker push "${IMAGE}:sha-${SHORT_SHA}"
|
||||
|
||||
# ── 2b. Build admin ──────────────────────────────────────────────────────────
|
||||
|
||||
build-admin:
|
||||
name: Build & push — admin
|
||||
needs: [ci, changes]
|
||||
if: needs.changes.outputs.admin == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Prune workspace
|
||||
run: |
|
||||
npx turbo prune admin --docker
|
||||
cp -r convex out/full/convex
|
||||
cp package-lock.json out/package-lock.json
|
||||
|
||||
- name: Authenticate with registry
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${{ secrets.STAGING_REGISTRY_USER }}:${{ secrets.STAGING_REGISTRY_TOKEN }}" | base64 -w 0)
|
||||
REGISTRY_HOST=$(echo "${{ secrets.STAGING_REGISTRY }}" | cut -d'/' -f1)
|
||||
echo "{\"auths\":{\"${REGISTRY_HOST}\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Build & push
|
||||
env:
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.STAGING_ADMIN_NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
||||
NEXT_PUBLIC_CONVEX_URL: ${{ secrets.STAGING_NEXT_PUBLIC_CONVEX_URL }}
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME: ${{ secrets.STAGING_NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME }}
|
||||
NEXT_PUBLIC_CLOUDINARY_API_KEY: ${{ secrets.STAGING_NEXT_PUBLIC_CLOUDINARY_API_KEY }}
|
||||
NEXT_PUBLIC_IMAGE_PROCESSING_API_URL: ${{ secrets.STAGING_NEXT_PUBLIC_IMAGE_PROCESSING_API_URL }}
|
||||
run: |
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
IMAGE="${{ secrets.STAGING_REGISTRY }}/admin"
|
||||
docker build \
|
||||
-f apps/admin/Dockerfile \
|
||||
--build-arg NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" \
|
||||
--build-arg NEXT_PUBLIC_CONVEX_URL="$NEXT_PUBLIC_CONVEX_URL" \
|
||||
--build-arg NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="$NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME" \
|
||||
--build-arg NEXT_PUBLIC_CLOUDINARY_API_KEY="$NEXT_PUBLIC_CLOUDINARY_API_KEY" \
|
||||
--build-arg NEXT_PUBLIC_IMAGE_PROCESSING_API_URL="$NEXT_PUBLIC_IMAGE_PROCESSING_API_URL" \
|
||||
--load -t "${IMAGE}:staging" ./out
|
||||
docker tag "${IMAGE}:staging" "${IMAGE}:sha-${SHORT_SHA}"
|
||||
docker push "${IMAGE}:staging"
|
||||
docker push "${IMAGE}:sha-${SHORT_SHA}"
|
||||
|
||||
# ── 3. Deploy ────────────────────────────────────────────────────────────────
|
||||
# Runs when at least one app changed and no build failed.
|
||||
# `always()` is required so the job isn't auto-skipped when one build job
|
||||
# was skipped (Gitea/GitHub skip downstream jobs of skipped jobs by default).
|
||||
|
||||
deploy:
|
||||
name: Deploy to staging VPS
|
||||
needs: [build-storefront, build-admin, changes]
|
||||
if: |
|
||||
always() &&
|
||||
(needs.changes.outputs.storefront == 'true' || needs.changes.outputs.admin == 'true') &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Write SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.STAGING_SSH_KEY }}" > ~/.ssh/staging
|
||||
chmod 600 ~/.ssh/staging
|
||||
|
||||
- name: Pull & restart containers on VPS
|
||||
env:
|
||||
REGISTRY: ${{ secrets.STAGING_REGISTRY }}
|
||||
REGISTRY_USER: ${{ secrets.STAGING_REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.STAGING_REGISTRY_TOKEN }}
|
||||
SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
SSH_PORT: ${{ secrets.STAGING_SSH_PORT }}
|
||||
CLERK_SECRET_KEY: ${{ secrets.STAGING_STOREFRONT_CLERK_SECRET_KEY }}
|
||||
ADMIN_CLERK_SECRET_KEY: ${{ secrets.STAGING_ADMIN_CLERK_SECRET_KEY }}
|
||||
CLOUDINARY_API_SECRET: ${{ secrets.STAGING_CLOUDINARY_API_SECRET }}
|
||||
STOREFRONT_CHANGED: ${{ needs.changes.outputs.storefront }}
|
||||
ADMIN_CHANGED: ${{ needs.changes.outputs.admin }}
|
||||
run: |
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d'/' -f1)
|
||||
COMPOSE_B64=$(sed "s|\${REGISTRY}|${REGISTRY}|g" deploy/staging/compose.yml | base64 -w 0)
|
||||
|
||||
ssh -i ~/.ssh/staging \
|
||||
-p "${SSH_PORT:-22}" \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${SSH_HOST}" bash -s << EOF
|
||||
set -euo pipefail
|
||||
|
||||
echo "${REGISTRY_TOKEN}" \
|
||||
| podman login "${REGISTRY_HOST}" \
|
||||
-u "${REGISTRY_USER}" --password-stdin --tls-verify=false
|
||||
|
||||
[ "${STOREFRONT_CHANGED}" = "true" ] && podman pull --tls-verify=false "${REGISTRY}/storefront:staging"
|
||||
[ "${ADMIN_CHANGED}" = "true" ] && podman pull --tls-verify=false "${REGISTRY}/admin:staging"
|
||||
|
||||
mkdir -p /opt/staging
|
||||
echo "${COMPOSE_B64}" | base64 -d > /opt/staging/compose.yml
|
||||
|
||||
printf 'CLERK_SECRET_KEY=%s\nADMIN_CLERK_SECRET_KEY=%s\nCLOUDINARY_API_SECRET=%s\n' \
|
||||
"${CLERK_SECRET_KEY}" "${ADMIN_CLERK_SECRET_KEY}" "${CLOUDINARY_API_SECRET}" \
|
||||
> /opt/staging/.env
|
||||
chmod 600 /opt/staging/.env
|
||||
|
||||
SERVICES=""
|
||||
[ "${STOREFRONT_CHANGED}" = "true" ] && SERVICES="\${SERVICES} storefront"
|
||||
[ "${ADMIN_CHANGED}" = "true" ] && SERVICES="\${SERVICES} admin"
|
||||
|
||||
cd /opt/staging
|
||||
podman compose up -d --force-recreate --remove-orphans \${SERVICES}
|
||||
|
||||
podman image prune -f
|
||||
EOF
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -30,7 +30,22 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
# Convex
|
||||
convex/_generated
|
||||
apps/admin/.env.staging
|
||||
apps/storefront/.env.staging
|
||||
convex/.env.staging
|
||||
|
||||
# Others
|
||||
seed-data-generator/
|
||||
.agent/
|
||||
.gitea/
|
||||
.cursor/
|
||||
.idea/
|
||||
.vscode/
|
||||
docs/
|
||||
.claude/
|
||||
.aiassistant/
|
||||
add-shadcn-ui-mcp.md
|
||||
gitea-cicd-troubleshooting.md
|
||||
.gitignore
|
||||
some_html_template.html
|
||||
notes.md
|
||||
68
apps/admin/Dockerfile
Normal file
68
apps/admin/Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
||||
# Build context: ./out (turbo prune admin --docker)
|
||||
# out/json/ — package.json files only → used by deps stage for layer caching
|
||||
# out/full/ — full pruned monorepo → used by builder stage for source
|
||||
# out/package-lock.json
|
||||
|
||||
# ── Stage 1: deps ────────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade npm to match the project's packageManager (npm@11). The package-lock.json
|
||||
# was generated with npm 11 — npm 10 (bundled with node:20) can't fully parse it,
|
||||
# causing turbo prune to generate an incomplete pruned lockfile and npm ci to miss
|
||||
# packages.
|
||||
RUN npm install -g npm@11 --quiet
|
||||
|
||||
COPY json/ .
|
||||
COPY package-lock.json .
|
||||
RUN npm ci
|
||||
|
||||
# ── Stage 2: builder ─────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY full/ .
|
||||
|
||||
# NEXT_PUBLIC_* vars are baked into the client bundle at build time by Next.js.
|
||||
# They must be present here (not just at runtime) or SSG/prerender fails.
|
||||
# Passed via --build-arg in CI. Note: Gitea secrets use a STAGING_/PROD_ prefix
|
||||
# which is stripped by the workflow before being forwarded here as build args.
|
||||
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
ARG NEXT_PUBLIC_CONVEX_URL
|
||||
ARG NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME
|
||||
ARG NEXT_PUBLIC_CLOUDINARY_API_KEY
|
||||
ARG NEXT_PUBLIC_IMAGE_PROCESSING_API_URL
|
||||
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
|
||||
NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL \
|
||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=$NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME \
|
||||
NEXT_PUBLIC_CLOUDINARY_API_KEY=$NEXT_PUBLIC_CLOUDINARY_API_KEY \
|
||||
NEXT_PUBLIC_IMAGE_PROCESSING_API_URL=$NEXT_PUBLIC_IMAGE_PROCESSING_API_URL \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npx turbo build --filter=admin
|
||||
|
||||
# ── Stage 3: runner ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3001
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/static ./apps/admin/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/public ./apps/admin/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "apps/admin/server.js"]
|
||||
12
apps/admin/eslint.config.mjs
Normal file
12
apps/admin/eslint.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const compat = new FlatCompat({ baseDirectory: __dirname });
|
||||
|
||||
const config = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{ ignores: [".next/**", "node_modules/**"] },
|
||||
];
|
||||
export default config;
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Next.js config commonly uses require */
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
outputFileTracingRoot: path.join(__dirname, "../.."),
|
||||
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
||||
turbopack: {
|
||||
root: path.join(__dirname, "..", ".."),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29,7 +29,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { ProductSearchSection } from "../../../components/shared/ProductSearchSection"
|
||||
import { ProductImageCarousel } from "../../../components/images/ProductImageCarousel"
|
||||
import {
|
||||
ProductImageCarousel,
|
||||
type ProductImage,
|
||||
} from "../../../components/images/ProductImageCarousel"
|
||||
import { ImageUploadSection } from "../../../components/images/ImageUploadSection"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
@@ -79,7 +82,7 @@ export default function ImagesPage() {
|
||||
</div>
|
||||
) : (
|
||||
<ProductImageCarousel
|
||||
images={images as any}
|
||||
images={images as ProductImage[]}
|
||||
onAddMore={() => setShowUpload(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,8 +53,8 @@ export default function EditProductPage() {
|
||||
categoryId: payload.categoryId as Id<"categories">,
|
||||
})
|
||||
router.push("/products")
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to save product. Please try again.")
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save product. Please try again.")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ export default function EditProductPage() {
|
||||
try {
|
||||
await archiveProduct({ id: productId })
|
||||
router.push("/products")
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to archive product.")
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to archive product.")
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ export default function NewProductPage() {
|
||||
categoryId: payload.categoryId as Id<"categories">,
|
||||
})
|
||||
router.push("/products")
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to create product. Please try again.")
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create product. Please try again.")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,11 +199,14 @@ export default function ProductsPage() {
|
||||
? searchResults === undefined
|
||||
: listStatus === "LoadingFirstPage"
|
||||
|
||||
const rawProducts = isSearching ? (searchResults ?? []) : listResults
|
||||
const rawProducts = useMemo(
|
||||
() => (isSearching ? (searchResults ?? []) : listResults),
|
||||
[isSearching, searchResults, listResults],
|
||||
)
|
||||
|
||||
const products = useMemo(() => {
|
||||
if (!sortField) return rawProducts
|
||||
return [...rawProducts].sort((a: any, b: any) => {
|
||||
return [...rawProducts].sort((a: PreviewProduct, b: PreviewProduct) => {
|
||||
const aVal: string =
|
||||
sortField === "name"
|
||||
? (a.name ?? "")
|
||||
@@ -235,8 +238,8 @@ export default function ProductsPage() {
|
||||
setVisibleCols((prev) => ({ ...prev, [key]: checked }))
|
||||
}
|
||||
|
||||
function openPreview(product: any) {
|
||||
setPreviewProduct(product as PreviewProduct)
|
||||
function openPreview(product: PreviewProduct) {
|
||||
setPreviewProduct(product)
|
||||
setPreviewOpen(true)
|
||||
}
|
||||
|
||||
@@ -374,7 +377,7 @@ export default function ProductsPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
products.map((product: any) => {
|
||||
products.map((product: PreviewProduct) => {
|
||||
const statusCfg =
|
||||
STATUS_CONFIG[product.status as keyof typeof STATUS_CONFIG]
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DM_Sans, Geist, Geist_Mono } from "next/font/google";
|
||||
import { DM_Sans } from "next/font/google";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { ConvexClientProvider } from "@repo/convex";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const dmSans = DM_Sans({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { useMutation } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
@@ -61,8 +62,8 @@ export function ImageUploadSection({
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errJson = await res.json().catch(() => ({}))
|
||||
throw new Error((errJson as any).detail ?? "Background removal failed")
|
||||
const errJson = (await res.json().catch(() => ({}))) as { detail?: string }
|
||||
throw new Error(errJson.detail ?? "Background removal failed")
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
@@ -100,8 +101,8 @@ export function ImageUploadSection({
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const errJson = await uploadRes.json().catch(() => ({}))
|
||||
throw new Error((errJson as any).error ?? "Upload failed")
|
||||
const errJson = (await uploadRes.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(errJson.error ?? "Upload failed")
|
||||
}
|
||||
|
||||
const { url } = await uploadRes.json()
|
||||
@@ -188,8 +189,14 @@ export function ImageUploadSection({
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Original</p>
|
||||
{localUrl && (
|
||||
<div className="aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
<img src={localUrl} alt="Original" className="h-full w-full object-contain" />
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
<Image
|
||||
src={localUrl}
|
||||
alt="Original"
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -199,11 +206,13 @@ export function ImageUploadSection({
|
||||
{isProcessing ? (
|
||||
<Skeleton className="aspect-square w-full rounded-md" />
|
||||
) : processedUrl ? (
|
||||
<div className="aspect-square overflow-hidden rounded-md border bg-[conic-gradient(#e5e7eb_25%,_#fff_25%,_#fff_50%,_#e5e7eb_50%,_#e5e7eb_75%,_#fff_75%)] bg-[length:16px_16px]">
|
||||
<img
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border bg-[conic-gradient(#e5e7eb_25%,_#fff_25%,_#fff_50%,_#e5e7eb_50%,_#e5e7eb_75%,_#fff_75%)] bg-[length:16px_16px]">
|
||||
<Image
|
||||
src={processedUrl}
|
||||
alt="Background removed"
|
||||
className="h-full w-full object-contain"
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : processingError ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -35,7 +36,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Delete02Icon, ImageAdd01Icon, DragDropVerticalIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface ProductImage {
|
||||
export interface ProductImage {
|
||||
_id: Id<"productImages">
|
||||
url: string
|
||||
alt?: string
|
||||
@@ -105,11 +106,12 @@ function SortableImageCard({
|
||||
|
||||
{/* Row 2 in DOM → Row 2 visually (middle stays middle) */}
|
||||
{/* Image — appears in CENTER */}
|
||||
<div className="w-28 aspect-square rotate-180 overflow-hidden rounded-md border bg-muted">
|
||||
<img
|
||||
<div className="relative w-28 aspect-square rotate-180 overflow-hidden rounded-md border bg-muted">
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.alt ?? "Product image"}
|
||||
className="h-full w-full object-contain"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductS
|
||||
|
||||
{!isLoading && results && results.length > 0 && (
|
||||
<ul className="max-w-sm divide-y rounded-md border">
|
||||
{results.map((product: any) => (
|
||||
{results.map((product: { _id: string; name: string }) => (
|
||||
<li key={product._id}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Store01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
||||
@@ -24,8 +24,8 @@ export function AcceptReturnButton({ orderId }: Props) {
|
||||
} else {
|
||||
toast.error((result as { success: false; code: string; message: string }).message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to accept return.")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to accept return.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export function CreateLabelButton({ orderId }: Props) {
|
||||
} else {
|
||||
toast.error((result as { success: false; code: string; message: string }).message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create shipping label.")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to create shipping label.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ export function IssueRefundButton({ orderId, total, currency }: Props) {
|
||||
await issueRefund({ orderId })
|
||||
toast.success("Refund issued.")
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to issue refund.")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to issue refund.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ export function MarkReturnReceivedButton({ orderId }: Props) {
|
||||
await markReceived({ id: orderId })
|
||||
toast.success("Return marked as received.")
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to mark return as received.")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to mark return as received.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ export function UpdateStatusDialog({ orderId, currentStatus }: Props) {
|
||||
const label = ORDER_STATUS_CONFIG[selectedStatus as OrderStatus]?.label
|
||||
toast.success(`Status updated to ${label ?? selectedStatus}`)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update status.")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update status.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductS
|
||||
|
||||
{!isLoading && results && results.length > 0 && (
|
||||
<ul className="max-w-sm divide-y rounded-md border">
|
||||
{results.map((product: any) => (
|
||||
{results.map((product: { _id: string; name: string }) => (
|
||||
<li key={product._id}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -68,8 +68,8 @@ export function CreateVariantDialog({
|
||||
|
||||
toast.success(`Variant "${values.name}" created`)
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create variant")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to create variant")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ export function EditVariantDialog({
|
||||
|
||||
toast.success(`"${values.name}" updated`)
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update variant")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update variant")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ function VariantActionsMenu({
|
||||
try {
|
||||
await updateVariant({ id: variant._id, isActive: !variant.isActive })
|
||||
toast.success(`"${variant.name}" ${variant.isActive ? "deactivated" : "activated"}`)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update variant")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update variant")
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
@@ -116,8 +116,8 @@ function VariantActionsMenu({
|
||||
await deleteVariant({ id: variant._id })
|
||||
toast.success(`"${variant.name}" deleted`)
|
||||
setDeleteOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to delete variant")
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete variant")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
81
apps/storefront/Dockerfile
Normal file
81
apps/storefront/Dockerfile
Normal file
@@ -0,0 +1,81 @@
|
||||
# Build context: ./out (turbo prune storefront --docker)
|
||||
# out/json/ — package.json files only → used by deps stage for layer caching
|
||||
# out/full/ — full pruned monorepo → used by builder stage for source
|
||||
# out/package-lock.json
|
||||
|
||||
# ── Stage 1: deps ────────────────────────────────────────────────────────────
|
||||
# Install ALL dependencies (dev + prod) using only the package.json tree.
|
||||
# This layer is shared with the builder stage and only rebuilds when
|
||||
# a package.json or the lock file changes — not when source code changes.
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade npm to match the project's packageManager (npm@11). The package-lock.json
|
||||
# was generated with npm 11 — npm 10 (bundled with node:20) can't fully parse it,
|
||||
# causing turbo prune to generate an incomplete pruned lockfile and npm ci to miss
|
||||
# packages like @heroui/react.
|
||||
RUN npm install -g npm@11 --quiet
|
||||
|
||||
COPY json/ .
|
||||
COPY package-lock.json .
|
||||
RUN npm ci
|
||||
|
||||
# ── Stage 2: builder ─────────────────────────────────────────────────────────
|
||||
# Full monorepo source + build artifact.
|
||||
# next build produces .next/standalone/ because output: "standalone" is set
|
||||
# in next.config.js — that's what makes the runner stage small.
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from the deps stage — not just /app/node_modules.
|
||||
# @heroui/react cannot be hoisted to the root by npm and is installed at
|
||||
# apps/storefront/node_modules/ instead. Copying only the root node_modules
|
||||
# would leave it missing. Copying all of /app/ brings both root and
|
||||
# workspace-level node_modules, then full/ layers the source on top.
|
||||
COPY --from=deps /app/ ./
|
||||
COPY full/ .
|
||||
|
||||
# NEXT_PUBLIC_* vars are baked into the client bundle at build time by Next.js.
|
||||
# They must be present here (not just at runtime) or SSG/prerender fails.
|
||||
# Passed via --build-arg in CI. Note: Gitea secrets use a STAGING_/PROD_ prefix
|
||||
# which is stripped by the workflow before being forwarded here as build args.
|
||||
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
ARG NEXT_PUBLIC_CONVEX_URL
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
|
||||
NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL \
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npx turbo build --filter=storefront
|
||||
|
||||
# ── Stage 3: runner ──────────────────────────────────────────────────────────
|
||||
# Minimal runtime image — only the standalone bundle, static assets, and public dir.
|
||||
# No source code, no dev dependencies, no build tools.
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3000
|
||||
|
||||
# Non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||
|
||||
# outputFileTracingRoot is set to the repo root, so the standalone directory mirrors
|
||||
# the full monorepo tree. server.js lands at apps/storefront/server.js inside
|
||||
# standalone/, not at the root. Static files and public/ must be copied separately.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/.next/static ./apps/storefront/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/public ./apps/storefront/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "apps/storefront/server.js"]
|
||||
11
apps/storefront/content/faq/account-and-security.md
Normal file
11
apps/storefront/content/faq/account-and-security.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: Account & security
|
||||
subtitle: Sign-in, password reset, and account access.
|
||||
order: 4
|
||||
---
|
||||
|
||||
### How do I reset my password?
|
||||
If you signed up with email and password, use the **Forgot password** link on the sign-in page. You will receive an email with instructions to reset your password. If you signed up with Google or another provider, sign in using that same option; there is no separate password to reset.
|
||||
|
||||
### Where can I see my order history?
|
||||
Sign in and go to [Order History](/account/orders). From there you can view your orders, see tracking information, and request returns where applicable.
|
||||
14
apps/storefront/content/faq/contact.md
Normal file
14
apps/storefront/content/faq/contact.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Contact
|
||||
subtitle: How to get in touch and how we handle complaints.
|
||||
order: 5
|
||||
---
|
||||
|
||||
### How can I contact you?
|
||||
You can reach us by filling in our [Contact Us](/support/contact-us) form or by emailing **service@thepetloft.co.uk**. We will respond as soon as we can.
|
||||
|
||||
### What topic should I choose on the contact form?
|
||||
Choose **Orders** for questions about delivery, returns, or a specific order. Choose **Support** for general questions about our products or services. Use **Products** for product-related inquiries and **Other** for anything else.
|
||||
|
||||
### How do I make a complaint?
|
||||
You can make a complaint by emailing **service@thepetloft.co.uk** or by using our [Contact Us](/support/contact-us) form. Please include your order number if your complaint relates to an order. We will acknowledge your complaint and work to resolve it.
|
||||
17
apps/storefront/content/faq/ordering-and-checkout.md
Normal file
17
apps/storefront/content/faq/ordering-and-checkout.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Ordering & checkout
|
||||
subtitle: Questions about placing orders and payment.
|
||||
order: 1
|
||||
---
|
||||
|
||||
### How do I place an order?
|
||||
Browse our shop, add items to your cart, and go to checkout. You must sign in or create an account to complete your order. Follow the steps to enter your shipping address, review your order, and pay securely.
|
||||
|
||||
### Do I need an account to checkout?
|
||||
Yes. You can browse and add items to your cart as a guest, but you must sign in or create an account to complete checkout.
|
||||
|
||||
### What payment methods do you accept?
|
||||
We accept major cards and digital wallets including Visa, Mastercard, Discover, Apple Pay, Google Pay, Link, Revolut Pay, Billie, Cartes, and Klarna. The options available at checkout may vary.
|
||||
|
||||
### Is my payment information secure?
|
||||
Yes. We process payments through Stripe. We do not store your full card number on our servers. Your payment details are handled securely by our payment provider.
|
||||
26
apps/storefront/content/faq/returns.md
Normal file
26
apps/storefront/content/faq/returns.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Returns
|
||||
subtitle: Returns, refunds, and non-returnable items.
|
||||
order: 3
|
||||
---
|
||||
|
||||
### How do I return an item?
|
||||
You have the right to withdraw from your order within **14 days** of receiving the goods. To start a return, inform us of your decision by email to [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk) or by post to The Pet Loft UK, Customer Services, 39a Walton Road, Woking, GU21 5DL. You can use the model withdrawal form on our [Returns & Refunds Policy](/support/returns) page, but it is not required. We will confirm next steps. You will incur the cost of returning the product; this amount will be deducted from your refund. Send the item back to **Fanaaka Ltd**, 39a Walton Road, Woking, GU21 5DL within 14 days of telling us you are withdrawing. For full details, see our [Returns & Refunds Policy](/support/returns).
|
||||
|
||||
### What items cannot be returned?
|
||||
The right of withdrawal does not apply to: goods made to your order or clearly tailored to your personal requirements; goods that may perish quickly or whose use-by date would expire rapidly; goods not suitable for return for reasons of health or hygiene if their seal has been broken after delivery; and goods that were, after delivery, inseparably mixed with other goods. If you are unsure whether your item can be returned, please [contact us](/support/contact-us) before sending it back.
|
||||
|
||||
### My item is wrong or damaged. What do I do?
|
||||
Please [contact us](/support/contact-us) as soon as possible with your order number and a description of the issue (and photos if helpful). We will arrange for the item to be returned and will cover return postage where the error or damage is on our side. For goods damaged in transit, notifying us promptly also helps us claim from the carrier.
|
||||
|
||||
### When will I receive my refund?
|
||||
After we receive your returned item (or evidence that you have sent it back), we will reimburse you without undue delay and in any event within **14 days**. The cost of return postage will be deducted from your refund amount. We use the same payment method you used for the order. Refunds may take a few extra business days to show on your bank or card statement.
|
||||
|
||||
### Where do I send my return?
|
||||
Send the goods to **Fanaaka Ltd**, 39a Walton Road, Woking, GU21 5DL. Please send them back within 14 days of informing us of your withdrawal. The deadline is met if you dispatch the goods before the 14-day period has expired.
|
||||
|
||||
### Do I have to pay for return postage?
|
||||
Yes. The customer incurs the cost of returning the product, and this amount will be deducted from the refund. You are also responsible for any loss in value caused by handling the item beyond what is needed to check its nature, characteristics, and functioning.
|
||||
|
||||
### How long do I have to request a return?
|
||||
You have **14 days** from the day you (or someone you nominate, other than the carrier) receive the goods to inform us of your decision to withdraw. Your communication must reach us before the 14-day period has expired. You must then send the goods back within 14 days of telling us you are withdrawing.
|
||||
17
apps/storefront/content/faq/shipping.md
Normal file
17
apps/storefront/content/faq/shipping.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Shipping
|
||||
subtitle: Delivery times, costs, and tracking.
|
||||
order: 2
|
||||
---
|
||||
|
||||
### Do you ship to my country?
|
||||
We currently ship only within the United Kingdom. We do not offer international shipping.
|
||||
|
||||
### How much is delivery?
|
||||
Free standard delivery is applied automatically at checkout on all orders over £40. Orders under £40 may incur a delivery charge, which will be shown before you pay.
|
||||
|
||||
### How long does delivery take?
|
||||
Standard delivery typically takes 3–5 working days for UK mainland. We partner with trusted carriers including DPD and Evri. Delivery times are estimates and may vary during peak periods.
|
||||
|
||||
### How can I track my order?
|
||||
Tracking information is available on your order detail page when signed in. Go to your [Order History](/account/orders), select the order, and view the tracking details there.
|
||||
0
apps/storefront/content/legal/.gitkeep
Normal file
0
apps/storefront/content/legal/.gitkeep
Normal file
25
apps/storefront/content/legal/data-protection.md
Normal file
25
apps/storefront/content/legal/data-protection.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Data Protection
|
||||
description: The Pet Loft data protection and GDPR information.
|
||||
lastUpdated: March 2025
|
||||
---
|
||||
|
||||
## Data controller
|
||||
|
||||
The Pet Loft is the data controller for the personal data we process in connection with our website and orders.
|
||||
|
||||
## Legal basis for processing
|
||||
|
||||
We process your data where necessary to perform our contract with you (e.g. fulfilling orders), where required by law, and where we have a legitimate interest (e.g. improving our services), in line with applicable data protection law.
|
||||
|
||||
## Retention
|
||||
|
||||
We retain your data only for as long as necessary to fulfil the purposes set out in our Privacy Policy and to comply with legal obligations.
|
||||
|
||||
## International transfers
|
||||
|
||||
If we transfer your data outside the UK or EEA, we ensure appropriate safeguards are in place as required by law.
|
||||
|
||||
## Complaints
|
||||
|
||||
You have the right to lodge a complaint with a supervisory authority if you believe our processing of your data infringes applicable law.
|
||||
286
apps/storefront/content/legal/general-terms-and-conditions.md
Normal file
286
apps/storefront/content/legal/general-terms-and-conditions.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# General Terms and Conditions
|
||||
|
||||
**The Pet Loft UK** | A division of Fanaaka Ltd, 39a Walton Road, GU21, United Kingdom
|
||||
**Website:** [www.thepetloft.co.uk](http://www.thepetloft.co.uk)
|
||||
**Last updated:** 02 February 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
These General Terms and Conditions of Business apply to all orders and deliveries between **The Pet Loft UK**, a division of Fanaaka Ltd, 39a Walton Road, GU21, United Kingdom (hereinafter: **"Pet Loft"**) and its customers via the online shop [www.thepetloft.co.uk](http://www.thepetloft.co.uk).
|
||||
|
||||
---
|
||||
|
||||
## 2. Order Process, Entry into a Contract, Quantity Limitation & Commercial Resale
|
||||
|
||||
### 2.1 Order Process
|
||||
|
||||
The Pet Loft offers its customers a comprehensive range for all matters concerning domestic pets. By clicking on the products or product descriptions, the customer navigates to the product details — e.g. details regarding the product design, size, colour, or flavour. The product is placed in the virtual shopping basket/cart by entering the requested quantity and clicking on the shopping basket icon.
|
||||
|
||||
By clicking on the **"Shopping Cart"** button, displayed in the top right-hand corner of the online shop, the customer navigates to an overview page and can at any time check the goods in the virtual shopping basket and, where necessary, make changes.
|
||||
|
||||
If a customer does not wish to purchase additional goods, they can continue via the **"To Order/Checkout"** button. Registered customers can enter their usernames and passwords here to automatically use their saved information for the order. Alternatively, the customer can register as a new customer and set up a customer account, or continue the purchase without setting up a customer account. In such a case, the customer must enter their address and invoice information on the following page.
|
||||
|
||||
By further clicking on the **"Continue"** button, the customer reaches the penultimate order stage — **"Overview"**. The customer has access to an overview of the order here, with details of the price (including the statutory VAT) and details of the delivery service and costs.
|
||||
|
||||
The order is placed by clicking on the **"Buy"** button. This constitutes a binding offer.
|
||||
|
||||
The Pet Loft does not charge any fees for the use of remote communication systems, but the customer may incur the usual costs associated with the use of these services from third parties (e.g. mobile operator, internet provider).
|
||||
|
||||
### 2.2 Entry into a Contract
|
||||
|
||||
**a.** The goods offered in the shop are sold exclusively to non-commercial individuals — i.e. only to consumers who conclude the legal transaction for purposes that can be attributed neither to their commercial nor to their independent professional activity. The offers appearing on our website [www.thepetloft.co.uk](http://www.thepetloft.co.uk) are therefore not aimed at businesses. Businesses are natural or legal persons, or partnerships with legal capacity, who, when concluding a legal transaction, act in the exercise of their commercial or self-employed professional activity.
|
||||
|
||||
**b.** The images of the range in the online shop are intended as an illustration and do not constitute binding offers for sale. By completing the order process by clicking on the **"Buy"** button, the customer makes a binding offer to enter into a purchase contract. The customer thereupon receives an automated confirmation of receipt of order by email (**order confirmation**). This order confirmation does not constitute acceptance of the offer. The contract with The Pet Loft is only concluded when The Pet Loft sends the ordered product to the customer and the shipping is confirmed by email (**shipping confirmation**).
|
||||
|
||||
**c.** Notwithstanding clause 2.2(b), if the customer chooses to pay in advance, a contract is already concluded when The Pet Loft sends the payment information. This payment information will be sent to the customer within **24 hours** of submitting the order. The order confirmation does not constitute payment information. In the case of payment in advance, the invoice amount shall be due upon receipt of the payment information and shall be paid within **7 days** of receipt by bank transfer to one of the accounts listed under clause 8.1(b). Receipt of the invoice amount on our account is decisive for compliance with the payment deadline. Should no payment be recorded on one of the accounts specified under clause 8.1(b) after 7 days, the customer's order will be automatically cancelled.
|
||||
|
||||
**d.** The contract language is **English**.
|
||||
|
||||
### 2.3 Contract Text
|
||||
|
||||
The text of the contract will be stored by us until the order has been processed in full, after which it will be archived in accordance with tax and commercial law. Upon receipt of the order by The Pet Loft, the purchaser will receive a separate confirmation email containing the essential contents of the contract, including the General Terms and Conditions valid at the time of the contract. If you lose your documents relating to your orders, please contact us — we will be happy to send you a copy of your order data.
|
||||
|
||||
### 2.4 Quantity Limitation, Maximum Order Value & Commercial Resale
|
||||
|
||||
The offered goods are sold in customary domestic quantities only, and only to persons of full age. The **commercial resale** of goods is not permitted. The Pet Loft reserves the right not to accept contractual offers that appear to be made for the purpose of the commercial resale of goods.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prices and Shipping Charges
|
||||
|
||||
All prices include **statutory VAT** and other price components, and are exclusive of any shipping costs.
|
||||
|
||||
We deliver within the **United Kingdom only**.
|
||||
|
||||
If you order products from The Pet Loft for delivery outside the EU, you may be subject to import duties and taxes, which will be levied once the package reaches the specified destination. Any additional charges for customs clearance must be borne by you. We have no control over these charges. Customs regulations vary widely from country to country, so you should contact your local customs office for more information.
|
||||
|
||||
---
|
||||
|
||||
## 4. Delivery
|
||||
|
||||
Deliveries are only made within the **United Kingdom**.
|
||||
|
||||
Unless stated otherwise in the offer or product details, delivery takes place within **1 to 3 business days**. The deadline for delivery begins on the day after the contract is concluded, except for payment in advance — in that case, the deadline begins on the day after the payment order has been issued. If the last day of the deadline falls on a Saturday, Sunday, or a public holiday recognised by the state at the place of delivery, the deadline is automatically extended to the next working day.
|
||||
|
||||
In the event that some of the ordered products are not in stock, The Pet Loft shall be entitled to provide **partial deliveries** at its own cost, provided this is acceptable to the customer.
|
||||
|
||||
In the event that The Pet Loft is unable to deliver the ordered product because it is not supplied by its own suppliers — and without culpability on the part of The Pet Loft — The Pet Loft may withdraw from the contract. In such a case, The Pet Loft shall inform the customer without delay and propose a comparable product. If a comparable product is not available, or if the customer does not wish to have that product delivered, The Pet Loft shall, without delay, reimburse any payments made by the customer. Deliveries are free of customs duties within the EU. In the case of delivery to countries outside the EU, customs duties, taxes, and other applicable levies shall be borne by the customer.
|
||||
|
||||
In the event that supplied products are damaged in transit, The Pet Loft customer service is to be contacted as soon as possible. This enables The Pet Loft to lodge a complaint with the carrier or transport insurer regarding the damage. Failure by the customer to provide notification of transport damage shall not affect the customer's statutory guarantee rights in any way.
|
||||
|
||||
In order to fulfil customer orders, The Pet Loft needs to pass on the customer's email address and, if available, a contact phone number to the delivery company authorised to deliver the goods. This forms part of the contract with The Pet Loft. The customer does not have the right to object to this. For further information, please see our **Data Protection** page.
|
||||
|
||||
---
|
||||
|
||||
## 5. Retention of Title
|
||||
|
||||
The goods shall remain the **property of The Pet Loft** until payment in full. Prior to the passing of ownership, pledging, ownership transfer by way of security, processing, or redesigning are not permitted without approval from The Pet Loft.
|
||||
|
||||
---
|
||||
|
||||
## 6. Right of Withdrawal
|
||||
|
||||
Consumers have a statutory right of withdrawal when concluding a distance selling contract. The Pet Loft provides the following information in accordance with the statutory model. A consumer is any natural person who enters into a legal transaction for purposes that are predominantly neither commercial nor self-employed. If customers have any further questions about cancellations, they can contact The Pet Loft customer service.
|
||||
|
||||
### Instructions on Withdrawal
|
||||
|
||||
#### Right of Withdrawal
|
||||
|
||||
You have the right to **withdraw from this contract within 14 days** without giving any reason.
|
||||
|
||||
The withdrawal period will expire after **14 days** from the day on which you acquire, or a third party (other than the carrier and as indicated by you), acquires physical possession of the goods.
|
||||
|
||||
To exercise the right of withdrawal, you must inform us at:
|
||||
|
||||
> **The Pet Loft UK**, Customer Services, 39a Walton Road, Woking, GU21 5DL
|
||||
> Email: [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk)
|
||||
|
||||
of your decision to withdraw from this contract by an unequivocal statement (e.g. a letter sent by post or email). You may use the attached model withdrawal form, but it is not obligatory.
|
||||
|
||||
To meet the withdrawal deadline, it is sufficient for you to send your communication concerning your exercise of the right of withdrawal **before** the withdrawal period has expired.
|
||||
|
||||
#### Effects of Withdrawal
|
||||
|
||||
If you withdraw from this contract, we shall reimburse to you **all payments received** from you, including the costs of delivery (with the exception of supplementary costs resulting from your choice of a type of delivery other than the least expensive type of standard delivery offered by us), without undue delay and in any event not later than **14 days** from the day on which we are informed about your decision to withdraw from this contract.
|
||||
|
||||
The **cost of returning the goods is borne by you** and will be **deducted from the refund amount**. We will carry out such reimbursement using **the same means of payment** as you used for the initial transaction, unless you have expressly agreed otherwise. We may withhold reimbursement until we have received the goods back, or you have supplied evidence of having sent back the goods, whichever is the earliest.
|
||||
|
||||
#### Return Address
|
||||
|
||||
Please send the goods back to:
|
||||
|
||||
> **Fanaaka Ltd**, 39a Walton Road, Woking, GU21 5DL
|
||||
|
||||
without undue delay and in any event not later than **14 days** from the day on which you communicate your withdrawal from this contract to us. The deadline is met if you send back the goods before the 14-day period has expired.
|
||||
|
||||
**You incur the cost of returning the goods; this amount will be deducted from your refund.** You are only liable for any diminished value of the goods resulting from handling beyond what is necessary to establish the nature, characteristics, and functioning of the goods.
|
||||
|
||||
---
|
||||
|
||||
### Exclusion of the Right of Withdrawal
|
||||
|
||||
The right of withdrawal does **not** apply in the event of delivery of:
|
||||
|
||||
- Goods that are not pre-produced and for which an individual selection or determination by the consumer is authoritative for their manufacture, or goods that are clearly tailored to the consumer's personal requirements.
|
||||
- Goods that may perish quickly or whose use-by date would expire rapidly.
|
||||
- Goods that are not suitable for return for reasons of health protection or hygiene, if their seal has been broken after delivery.
|
||||
- Goods that were, after delivery, inseparably mixed with other goods.
|
||||
|
||||
---
|
||||
|
||||
### Model Withdrawal Form
|
||||
|
||||
*(Should you wish to cancel your contract with The Pet Loft, please complete this form and return it to:)*
|
||||
|
||||
**To:** The Pet Loft UK, Customer Service, 39a Walton Road, Woking, GU21 5DL
|
||||
**Email:** [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk)
|
||||
|
||||
I/We (\*) hereby give notice that I/We (\*) withdraw from my/our (\*) contract of sale of the following goods (\*) / for the provision of the following service (\*):
|
||||
|
||||
- **Ordered on (\*):** _______________
|
||||
- **Received on (\*):** _______________
|
||||
- **Name of consumer(s):** _______________
|
||||
- **Address of consumer(s):** _______________
|
||||
- **Signature of consumer(s):** _______________ *(only if this form is submitted on paper)*
|
||||
- **Date:** _______________
|
||||
|
||||
*(\*) Delete as appropriate.*
|
||||
|
||||
---
|
||||
|
||||
## 7. Guarantee and Liability
|
||||
|
||||
The **statutory guarantee provisions** apply.
|
||||
|
||||
**Veterinary diet feed** should only be used where recommended and under regular monitoring by a veterinarian. The veterinarian should be visited regularly (every 6 months) during the feeding period for check-up examinations, and without delay in the event of any deterioration in the domestic pet's condition. The Pet Loft is not liable for the consequences of inappropriate or unnecessary use of veterinary diet feed.
|
||||
|
||||
**Medicines** should only be used as recommended and under regular supervision by the family veterinarian. The family veterinarian should be consulted regularly during use for check-ups, and immediately if the pet's health deteriorates. The Pet Loft accepts no liability for the consequences of improper or medically undeclared use of medicines.
|
||||
|
||||
---
|
||||
|
||||
## 8. Payment Methods, Vouchers, Default Interest & Invoices
|
||||
|
||||
### 8.1 Payment Methods
|
||||
|
||||
We offer the following payment options:
|
||||
|
||||
- **a. Credit and/or debit card**
|
||||
- **b. Payment in advance**
|
||||
- **c. PayPal**
|
||||
- **d. Apple Pay**
|
||||
|
||||
We reserve the right, for each order and in individual cases, or depending on the delivery method selected by the customer, not to offer certain payment methods or to accept only certain payment methods, and to refer to alternative payment methods. **Payment by cash or cheque is not possible**, and The Pet Loft is not liable for any loss in such cases.
|
||||
|
||||
#### a. Payment by Credit Card
|
||||
|
||||
If payment is made by credit card, the amount will be debited within **one week** after the goods have been dispatched. We accept **MasterCard, Visa, Diners Club, and American Express**.
|
||||
|
||||
#### b. Payment in Advance
|
||||
|
||||
If the customer wishes to pay in advance, the invoice amount is to be transferred to one of the accounts listed below within **7 days** of receipt of the payment information. The goods will only be dispatched **after receipt of payment**. If full payment is not received within seven days of the payment information being sent, the order will be cancelled.
|
||||
|
||||
**Our bank details:**
|
||||
|
||||
| Field | Details |
|
||||
|---|---|
|
||||
| Account name | Fanaaka Ltd |
|
||||
| Sort code | 23-11-85 |
|
||||
| Account number | 20952130 |
|
||||
|
||||
#### c. Payment via PayPal
|
||||
|
||||
You pay directly via your PayPal account. After submitting your order, you will be redirected to PayPal to authorise the order value. As soon as our PayPal account has been notified of your authorisation, shipment will take place — depending on the delivery time indicated for the item. Your PayPal account will be debited with the actual invoice amount (after deduction of any discounts, gift vouchers, etc.) immediately after authorisation.
|
||||
|
||||
#### d. Payment via Apple Pay
|
||||
|
||||
Apple Pay is available as a payment method for **iOS devices** on our website. You can select this payment method in the checkout area via Safari, and pay with a linked payment card. After submitting your order, you will be redirected to Apple to authorise payment. Once we are notified of your authorisation, shipping will begin — depending on the shipping time indicated on the product. The actual invoice amount, minus any discounts or vouchers, will be debited immediately after authorisation.
|
||||
|
||||
### 8.2 Vouchers
|
||||
|
||||
When redeeming promotional vouchers, the specifically applicable redemption conditions must be observed. The relevant information can be found on the vouchers themselves.
|
||||
|
||||
### 8.3 Default Interest and Other Default Damages
|
||||
|
||||
If the customer is in **default of payment**, the purchase price shall be subject to interest at the **statutory default interest rate** during the period of default. The Pet Loft reserves the right to claim higher damages for default, subject to proof.
|
||||
|
||||
### 8.4 Invoices
|
||||
|
||||
The Pet Loft has the right to invoice the customer **electronically**. Electronic invoices will be sent to the customer via email in **PDF format**. The invoiced sales tax does not entitle the customer to an input tax deduction.
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Protection
|
||||
|
||||
The Pet Loft takes the **protection of its customers' data** very seriously. The Pet Loft data protection declaration can be viewed on our **Data Privacy** page.
|
||||
|
||||
---
|
||||
|
||||
## 10. Marketing & Customer Communication
|
||||
|
||||
If the customer enters into a contract for the purchase of a product or service with The Pet Loft and provides their email address, The Pet Loft may use this email address for **direct advertising of similar goods or services**.
|
||||
|
||||
The customer has the right to **object to the use of their email address** for this purpose at any time, without incurring any costs other than the transmission costs according to the base rates. Each email contains an **unsubscribe link** for this purpose. Alternatively, the objection can be submitted at any time by email to [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk).
|
||||
|
||||
---
|
||||
|
||||
## 12. The Pet Loft Subscription Programme
|
||||
|
||||
### 12.1 General
|
||||
|
||||
The **Pet Loft Repeat** (hereinafter: **"subscription"**) allows registered customers to set up regular, automated orders for subscription-eligible items to be delivered at pre-determined intervals, without the need for manual repeat orders. All short-term or temporary promotional items are excluded from the subscription.
|
||||
|
||||
Subscription-eligible items that are part of a confirmed or completed order can be converted into the Pet Loft Repeat in your **"My Pet Loft"** customer account under **"My Orders"**.
|
||||
|
||||
Each automated subscription order constitutes a **binding offer** to The Pet Loft to conclude a sales contract. The contract is only concluded when The Pet Loft ships the ordered item to the customer and confirms dispatch by email (**dispatch confirmation**) within five working days of receipt of the automated order. Should the customer not receive confirmation of dispatch within the aforementioned period, a contract does not come into effect.
|
||||
|
||||
Before an automated order is processed, The Pet Loft will send the customer a **reminder email** allowing the customer to cancel or change the order.
|
||||
|
||||
### 12.2 Subscription Discount
|
||||
|
||||
The Pet Loft offers a **subscription discount** on the current standard price of specific items. Information about discount levels and eligible items can be found on the Pet Loft Repeat FAQ page. The subscription discount applied is the level of discount valid for subscription products at the time the order is processed. Certain subscription and product details (including price, discount, and availability) may change over time. Each subscription order is subject to the subscription and product details that currently apply. The Pet Loft reserves the right to **alter the subscription discount at any time**. The subscription discount cannot be combined with other discounts.
|
||||
|
||||
### 12.3 Availability of Goods
|
||||
|
||||
Should a particular item in your subscription order be **out of stock** on the scheduled delivery date, the order for that item will be automatically cancelled.
|
||||
|
||||
### 12.4 Duration, Changes, and Termination of Subscription
|
||||
|
||||
The subscription has **no minimum term**. Delivery intervals can be freely selected in weeks, but must be a minimum of **3 weeks** and a maximum of **12 weeks**. Changes, pausing, and cancellation of the subscription are possible at any time in the customer account under **"The Pet Loft Repeat"**.
|
||||
|
||||
The Pet Loft may amend these Terms & Conditions for the Pet Loft Repeat at any time by publishing the updated Terms & Conditions on [www.thepetloft.co.uk](http://www.thepetloft.co.uk) and by notifying the customer in advance of any significant changes. By continuing participation in the Pet Loft Repeat subscription service, the customer agrees to these changes. If the customer does not agree to any changes, the customer must cancel the subscription. The Pet Loft is entitled to cancel a subscription in writing at any time without stating a reason.
|
||||
|
||||
### 12.5 Payment Methods
|
||||
|
||||
Items ordered as part of the subscription service can only be paid for by **debit/credit card** or **PayPal**. The prerequisite for payment by these methods is that the data in the customer account is up-to-date and complete.
|
||||
|
||||
### 12.6 Miscellaneous
|
||||
|
||||
Should any provision in these Terms & Conditions be found to be void, invalid, or for any reason unenforceable, the validity and enforceability of the remaining Terms & Conditions shall not be affected thereby.
|
||||
|
||||
---
|
||||
|
||||
## 14. Alternative Dispute Resolution
|
||||
|
||||
We are neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board. Nevertheless, we endeavour to find an **amicable solution** to any differences of opinion with our customers. If a customer is not satisfied with one of our offers, they are welcome to contact us at [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk).
|
||||
|
||||
---
|
||||
|
||||
## 15. Final Provisions
|
||||
|
||||
Should any provision of these Terms and Conditions be or become invalid or unenforceable, the validity or enforceability of the other provisions shall not be affected thereby.
|
||||
|
||||
**United Kingdom law** applies, by way of exclusion of the UN Convention on Contracts for the International Sale of Goods (CISG). This choice of applicable law only applies in so far as the protection granted by mandatory provisions of the law of the state in which the consumer has their habitual residence at the time of their order is not withdrawn.
|
||||
|
||||
---
|
||||
|
||||
## 16. Printed Version of the General Terms and Conditions
|
||||
|
||||
To view a printer-friendly version of these General Terms and Conditions, click on the **printer icon** at the top of the page, in the upper right corner. Alternatively, to save a copy to your device, click on the **PDF icon**.
|
||||
|
||||
To open these Terms and Conditions as a PDF file, you will need **Adobe Reader**, which can be downloaded free of charge.
|
||||
|
||||
---
|
||||
|
||||
*Status of these General Terms and Conditions of Business: 02 February 2026.*
|
||||
25
apps/storefront/content/legal/privacy-policy.md
Normal file
25
apps/storefront/content/legal/privacy-policy.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Privacy Policy
|
||||
description: How The Pet Loft collects, uses, and protects your personal information.
|
||||
lastUpdated: March 2025
|
||||
---
|
||||
|
||||
## Information we collect
|
||||
|
||||
We collect information you provide when you create an account, place an order, or contact us. This may include your name, email address, delivery address, and payment details as necessary to fulfil your order.
|
||||
|
||||
## How we use your information
|
||||
|
||||
We use your information to process orders, communicate with you about your account and orders, improve our services, and comply with legal obligations. We do not sell your personal data to third parties.
|
||||
|
||||
## Data security
|
||||
|
||||
We take reasonable technical and organisational measures to protect your personal data against unauthorised access, loss, or misuse.
|
||||
|
||||
## Your rights
|
||||
|
||||
You may request access to, correction of, or deletion of your personal data in line with applicable law. Contact us to exercise these rights.
|
||||
|
||||
## Updates
|
||||
|
||||
We may update this privacy policy from time to time. The “Last updated” date at the top of this page will be revised when changes are made.
|
||||
21
apps/storefront/content/legal/terms-of-service.md
Normal file
21
apps/storefront/content/legal/terms-of-service.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Terms of Service
|
||||
description: The Pet Loft terms of service and conditions of use for our website and services.
|
||||
lastUpdated: March 2025
|
||||
---
|
||||
|
||||
## Acceptance of terms
|
||||
|
||||
By accessing and using The Pet Loft website and services, you agree to be bound by these Terms of Service. If you do not agree, please do not use our site.
|
||||
|
||||
## Use of the service
|
||||
|
||||
You may use our website for lawful purposes only. You must not use the site in any way that could damage, disable, or impair the service or interfere with any other party’s use of the site.
|
||||
|
||||
## Orders and payment
|
||||
|
||||
When you place an order, you are offering to purchase goods subject to these terms. We reserve the right to refuse or cancel orders at our discretion. Payment is due at checkout as specified.
|
||||
|
||||
## Changes
|
||||
|
||||
We may update these terms from time to time. The “Last updated” date at the top of this page will be revised when changes are made. Continued use of the site after changes constitutes acceptance of the updated terms.
|
||||
12
apps/storefront/eslint.config.mjs
Normal file
12
apps/storefront/eslint.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const compat = new FlatCompat({ baseDirectory: __dirname });
|
||||
|
||||
const config = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{ ignores: [".next/**", "node_modules/**"] },
|
||||
];
|
||||
export default config;
|
||||
@@ -1,7 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports -- Next.js config commonly uses require */
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
// Required in a monorepo: tells Next.js to trace files from the repo root
|
||||
// so the standalone bundle includes files from packages/
|
||||
outputFileTracingRoot: path.join(__dirname, "../.."),
|
||||
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
||||
turbopack: {
|
||||
root: path.join(__dirname, "..", ".."),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,6 +18,10 @@
|
||||
"@repo/utils": "*",
|
||||
"@stripe/react-stripe-js": "^5.6.0",
|
||||
"@stripe/stripe-js": "^8.8.0",
|
||||
"framer-motion": "^11.0.0"
|
||||
"framer-motion": "^11.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { AnnouncementBar } from "../components/layout/AnnouncementBar";
|
||||
import { Header } from "../components/layout/header/Header";
|
||||
import { SessionCartMerge } from "../lib/session/SessionCartMerge";
|
||||
import { StoreUserSync } from "../lib/session/StoreUserSync";
|
||||
@@ -18,7 +19,7 @@ const dmSans = DM_Sans({
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600", "700"],
|
||||
weight: ["100", "400", "600", "700"],
|
||||
variable: "--font-fraunces",
|
||||
});
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function RootLayout({
|
||||
<SessionCartMerge />
|
||||
<StoreUserSync />
|
||||
<CartUIProvider>
|
||||
<AnnouncementBar />
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
|
||||
15
apps/storefront/src/app/legal/data-protection/page.tsx
Normal file
15
apps/storefront/src/app/legal/data-protection/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LegalDocPage } from "@/components/legal/LegalDocPage";
|
||||
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const doc = getLegalDoc("data-protection");
|
||||
return {
|
||||
title: doc?.data.title ?? "Data Protection",
|
||||
description: doc?.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DataProtectionPage() {
|
||||
return <LegalDocPage slug="data-protection" />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LegalDocPage } from "@/components/legal/LegalDocPage";
|
||||
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const doc = getLegalDoc("general-terms-and-conditions");
|
||||
return {
|
||||
title: doc?.data.title ?? "General Terms and Conditions",
|
||||
description: doc?.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default function GeneralTermsAndConditionsPage() {
|
||||
return <LegalDocPage slug="general-terms-and-conditions" />;
|
||||
}
|
||||
15
apps/storefront/src/app/legal/privacy-policy/page.tsx
Normal file
15
apps/storefront/src/app/legal/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LegalDocPage } from "@/components/legal/LegalDocPage";
|
||||
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const doc = getLegalDoc("privacy-policy");
|
||||
return {
|
||||
title: doc?.data.title ?? "Privacy Policy",
|
||||
description: doc?.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return <LegalDocPage slug="privacy-policy" />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LegalDocPage } from "@/components/legal/LegalDocPage";
|
||||
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const doc = getLegalDoc("return-and-refund-policy");
|
||||
return {
|
||||
title: doc?.data.title ?? "Return and Refund Policy",
|
||||
description: doc?.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ReturnAndRefundPolicyPage() {
|
||||
return <LegalDocPage slug="return-and-refund-policy" />;
|
||||
}
|
||||
15
apps/storefront/src/app/legal/terms-of-service/page.tsx
Normal file
15
apps/storefront/src/app/legal/terms-of-service/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LegalDocPage } from "@/components/legal/LegalDocPage";
|
||||
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const doc = getLegalDoc("terms");
|
||||
return {
|
||||
title: doc?.data.title ?? "Terms of Service",
|
||||
description: doc?.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
return <LegalDocPage slug="terms-of-service" />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { RecentlyAddedSection } from "../components/sections/hompepage/products-
|
||||
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 { CustomerConfidenceBooster } from "../components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "../components/sections/TrustAndCredibility";
|
||||
import { Toast } from "@heroui/react";
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -21,7 +21,7 @@ export default function HomePage() {
|
||||
<RecentlyAddedSection />
|
||||
<SpecialOffersSection />
|
||||
<TopPicksSection />
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
<NewsletterSection />
|
||||
</main>
|
||||
);
|
||||
|
||||
152
apps/storefront/src/app/support/contact-us/page.tsx
Normal file
152
apps/storefront/src/app/support/contact-us/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ContactForm } from "@/components/contact/ContactForm";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact Us",
|
||||
description:
|
||||
"Get in touch with The Pet Loft. Postal address, support and service emails, and send us an inquiry.",
|
||||
};
|
||||
|
||||
function FacebookIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InstagramIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TwitterIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const socialLinks = [
|
||||
{ href: "https://facebook.com", label: "Facebook", icon: FacebookIcon },
|
||||
{ href: "https://instagram.com", label: "Instagram", icon: InstagramIcon },
|
||||
{ href: "https://twitter.com", label: "Twitter / X", icon: TwitterIcon },
|
||||
];
|
||||
|
||||
export default function ContactUsPage() {
|
||||
return (
|
||||
<main className="mx-auto min-w-0 max-w-[1400px] px-4 py-8 md:px-6 md:py-12">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
|
||||
Contact Us
|
||||
</h1>
|
||||
<p className="mt-2 text-[#3d5554]">
|
||||
We'd love to hear from you. Use the form to send an inquiry or find our details below.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-16">
|
||||
{/* Left column: address, emails, socials */}
|
||||
<section className="flex flex-col gap-8" aria-label="Contact details">
|
||||
<section aria-labelledby="postal-heading">
|
||||
<h2
|
||||
id="postal-heading"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
|
||||
>
|
||||
Postal address
|
||||
</h2>
|
||||
<address className="mt-2 not-italic text-[#1a2e2d] leading-relaxed">
|
||||
The Pet Loft
|
||||
<br />
|
||||
123 High Street
|
||||
<br />
|
||||
London, SW1A 1AA
|
||||
<br />
|
||||
United Kingdom
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="emails-heading">
|
||||
<h2
|
||||
id="emails-heading"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
|
||||
>
|
||||
Support and service emails
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1 text-[#1a2e2d]">
|
||||
<li>
|
||||
<a
|
||||
href="mailto:support@thepetloft.com"
|
||||
className="text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
support@thepetloft.com
|
||||
</a>
|
||||
<span className="ml-1 text-[#3d5554]">— general support</span>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:service@thepetloft.com"
|
||||
className="text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
service@thepetloft.com
|
||||
</a>
|
||||
<span className="ml-1 text-[#3d5554]">— orders & delivery</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="follow-heading">
|
||||
<h2
|
||||
id="follow-heading"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
|
||||
>
|
||||
Follow us
|
||||
</h2>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
{socialLinks.map(({ href, label, icon: Icon }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f0f8f7] text-[#3d5554] transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
|
||||
>
|
||||
<Icon />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* Right column: Inquiries form */}
|
||||
<section aria-labelledby="inquiries-heading">
|
||||
<h2
|
||||
id="inquiries-heading"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
|
||||
>
|
||||
Inquiries
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[#3d5554]">
|
||||
Send us a message and we'll get back to you as soon as we can.
|
||||
</p>
|
||||
<div className="mt-5">
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="mt-10 text-sm text-[#3d5554]">
|
||||
<Link
|
||||
href="/shop"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
← Back to shop
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
44
apps/storefront/src/app/support/faqs/page.tsx
Normal file
44
apps/storefront/src/app/support/faqs/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Link from "next/link";
|
||||
import { FaqPageView } from "@/components/support/FaqPageView";
|
||||
import { getFaqSections } from "@/lib/faq/getFaqSections";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "FAQs",
|
||||
description:
|
||||
"Frequently asked questions about ordering, shipping, returns, your account, and how to contact The Pet Loft.",
|
||||
};
|
||||
|
||||
export default function FaqsPage() {
|
||||
const sections = getFaqSections();
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8 md:px-6 md:py-12">
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
← Back to Shop
|
||||
</Link>
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
|
||||
Frequently asked questions
|
||||
</h1>
|
||||
<p className="mt-2 text-[#3d5554]">
|
||||
Select a topic below to see common questions and answers.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<FaqPageView sections={sections} lastUpdated="March 2025" />
|
||||
</div>
|
||||
<p className="mt-10 text-sm text-[#3d5554]">
|
||||
Can't find what you need?{" "}
|
||||
<Link
|
||||
href="/support/contact-us"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
apps/storefront/src/app/support/payment-security/page.tsx
Normal file
72
apps/storefront/src/app/support/payment-security/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Payment Security",
|
||||
description:
|
||||
"The Pet Loft payment security: how we keep your payment details safe with industry-standard encryption.",
|
||||
};
|
||||
|
||||
export default function PaymentSecurityPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
← Back to Shop
|
||||
</Link>
|
||||
<article className="prose prose-[#1a2e2d] max-w-none">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
|
||||
Payment Security
|
||||
</h1>
|
||||
<p className="mt-2 text-[#3d5554]">Last updated: March 2025</p>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Secure Checkout
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
All payments are processed securely through Stripe, a PCI-compliant
|
||||
payment provider trusted by millions of businesses worldwide. We
|
||||
never store your full card details on our servers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Industry Standard Protection
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
Our checkout uses TLS encryption to protect your data in transit.
|
||||
Stripe is certified to PCI Service Provider Level 1, the highest
|
||||
standard in the payments industry. Your card information is
|
||||
tokenised and never exposed to our systems.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Accepted Payment Methods
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
We accept major credit and debit cards (Visa, Mastercard, Discover),
|
||||
Apple Pay, Google Pay, and Klarna. Choose your preferred method at
|
||||
checkout.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="mt-10 text-sm text-[#3d5554]">
|
||||
Questions about security?{" "}
|
||||
<Link
|
||||
href="/contact"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
131
apps/storefront/src/app/support/returns/page.tsx
Normal file
131
apps/storefront/src/app/support/returns/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Returns & Refunds Policy",
|
||||
description:
|
||||
"The Pet Loft returns policy: how to return items, refund process, and conditions for easy returns.",
|
||||
};
|
||||
|
||||
export default function ReturnsPolicyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
← Back to Shop
|
||||
</Link>
|
||||
<article className="prose prose-[#1a2e2d] max-w-none">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
|
||||
Returns & Refunds Policy
|
||||
</h1>
|
||||
<p className="mt-2 text-[#3d5554]">Last updated: 02 February 2026</p>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Right of Withdrawal
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
You have the right to withdraw from your contract within 14 days
|
||||
without giving any reason. The withdrawal period expires 14 days
|
||||
from the day on which you (or a third party indicated by you, other
|
||||
than the carrier) acquire physical possession of the goods.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
How to withdraw
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
To exercise the right of withdrawal, you must inform us at The Pet
|
||||
Loft UK, Customer Services, 39a Walton Road, Woking, GU21 5DL, or by
|
||||
email at{" "}
|
||||
<a
|
||||
href="mailto:service@thepetloft.co.uk"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
service@thepetloft.co.uk
|
||||
</a>{" "}
|
||||
of your decision by an unequivocal statement (e.g. a letter by post
|
||||
or email). You may use the model withdrawal form, but it is not
|
||||
obligatory. To meet the deadline, it is sufficient for you to send
|
||||
your communication before the withdrawal period has expired.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Effects of withdrawal
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
If you withdraw, we shall reimburse all payments received from you,
|
||||
including delivery costs (except supplementary costs from choosing a
|
||||
delivery type other than our least expensive standard option),
|
||||
without undue delay and in any event not later than 14 days from the
|
||||
day we are informed of your withdrawal. The cost of returning the
|
||||
goods is borne by you and will be deducted from the refund amount.
|
||||
We will use the same means of payment as you used for the initial
|
||||
transaction unless you have expressly agreed otherwise. We may
|
||||
withhold reimbursement until we have received the goods back or you
|
||||
have supplied evidence of having sent them back, whichever is the
|
||||
earliest.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Return address
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
Please send the goods back to: <strong>Fanaaka Ltd</strong>, 39a
|
||||
Walton Road, Woking, GU21 5DL without undue delay and in any event
|
||||
not later than 14 days from the day you communicate your withdrawal.
|
||||
The deadline is met if you send the goods back before the 14-day
|
||||
period has expired. You incur the cost of returning the goods; this
|
||||
amount will be deducted from your refund. You are only liable for
|
||||
any diminished value resulting from handling
|
||||
beyond what is necessary to establish the nature, characteristics,
|
||||
and functioning of the goods.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Exclusions
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
The right of withdrawal does not apply to:
|
||||
</p>
|
||||
<ul className="list-inside list-disc space-y-1 text-[#1a2e2d]">
|
||||
<li>
|
||||
Goods made to order or clearly tailored to your personal
|
||||
requirements
|
||||
</li>
|
||||
<li>
|
||||
Goods that may perish quickly or whose use-by date would expire
|
||||
rapidly
|
||||
</li>
|
||||
<li>
|
||||
Goods not suitable for return for reasons of health or hygiene if
|
||||
their seal has been broken after delivery
|
||||
</li>
|
||||
<li>Goods that were, after delivery, inseparably mixed with other goods</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="mt-10 text-sm text-[#3d5554]">
|
||||
Need help?{" "}
|
||||
<Link
|
||||
href="/support/contact-us"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
71
apps/storefront/src/app/support/shipping/page.tsx
Normal file
71
apps/storefront/src/app/support/shipping/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Shipping Policy",
|
||||
description:
|
||||
"The Pet Loft shipping policy: free delivery on orders over £40, delivery times, and carrier information.",
|
||||
};
|
||||
|
||||
export default function ShippingPolicyPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
← Back to Shop
|
||||
</Link>
|
||||
<article className="prose prose-[#1a2e2d] max-w-none">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
|
||||
Shipping Policy
|
||||
</h1>
|
||||
<p className="mt-2 text-[#3d5554]">Last updated: March 2025</p>
|
||||
|
||||
<section className="mt-6 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Free Delivery on Orders Over £40
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
We offer free standard delivery on all orders over £40. The discount
|
||||
is automatically applied at checkout — no code required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Delivery Times
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
Standard delivery typically takes 3–5 working days for UK mainland.
|
||||
We partner with trusted carriers including DPD and Evri to get your
|
||||
pet supplies to you safely and on time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4">
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
Terms & Conditions
|
||||
</h2>
|
||||
<p className="text-[#1a2e2d] leading-relaxed">
|
||||
Free shipping applies to UK mainland only. Orders under £40 may
|
||||
incur a delivery charge. Delivery times are estimates and may vary
|
||||
during peak periods. We reserve the right to exclude certain items
|
||||
from free delivery promotions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="mt-10 text-sm text-[#3d5554]">
|
||||
Questions?{" "}
|
||||
<Link
|
||||
href="/contact"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Chip, RadioGroup, Radio, Label, Skeleton } from "@heroui/react";
|
||||
import { Button, Chip, RadioGroup, Radio, Skeleton } from "@heroui/react";
|
||||
import type { CheckoutAddress } from "@/lib/checkout/types";
|
||||
|
||||
type AddressSelectorProps = {
|
||||
|
||||
230
apps/storefront/src/components/contact/ContactForm.tsx
Normal file
230
apps/storefront/src/components/contact/ContactForm.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import type { Key } from "react-aria";
|
||||
import {
|
||||
Form,
|
||||
TextField,
|
||||
Label,
|
||||
Input,
|
||||
TextArea,
|
||||
FieldError,
|
||||
Button,
|
||||
Spinner,
|
||||
toast,
|
||||
Select,
|
||||
ListBox,
|
||||
} from "@heroui/react";
|
||||
|
||||
const TOPIC_OPTIONS = [
|
||||
{ key: "products", label: "Products" },
|
||||
{ key: "orders", label: "Orders" },
|
||||
{ key: "support", label: "Support" },
|
||||
{ key: "other", label: "Other" },
|
||||
] as const;
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_NAME = 200;
|
||||
const MAX_EMAIL = 254;
|
||||
const MAX_MESSAGE = 5000;
|
||||
|
||||
export function ContactForm() {
|
||||
const submitMessage = useMutation(api.messages.submit);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [topicKey, setTopicKey] = useState<Key | null>(null);
|
||||
const [topicError, setTopicError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setTopicError(null);
|
||||
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const fullName = (formData.get("fullName") as string)?.trim() ?? "";
|
||||
const email = (formData.get("email") as string)?.trim() ?? "";
|
||||
const message = (formData.get("message") as string)?.trim() ?? "";
|
||||
|
||||
// Client-side validation
|
||||
if (!fullName) {
|
||||
toast.danger("Please enter your full name.");
|
||||
return;
|
||||
}
|
||||
if (fullName.length > MAX_NAME) {
|
||||
toast.danger(`Full name must be at most ${MAX_NAME} characters.`);
|
||||
return;
|
||||
}
|
||||
if (!email) {
|
||||
toast.danger("Please enter your work email.");
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
toast.danger("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
if (email.length > MAX_EMAIL) {
|
||||
toast.danger("Email must be at most 254 characters.");
|
||||
return;
|
||||
}
|
||||
const topic = topicKey as string | null;
|
||||
if (!topic || !TOPIC_OPTIONS.some((o) => o.key === topic)) {
|
||||
setTopicError("Please select a topic.");
|
||||
toast.danger("Please select a topic.");
|
||||
return;
|
||||
}
|
||||
if (!message) {
|
||||
toast.danger("Please enter your message.");
|
||||
return;
|
||||
}
|
||||
if (message.length > MAX_MESSAGE) {
|
||||
toast.danger(`Message must be at most ${MAX_MESSAGE} characters.`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await submitMessage({
|
||||
fullName,
|
||||
email,
|
||||
topic: topic as "products" | "orders" | "support" | "other",
|
||||
message,
|
||||
});
|
||||
toast.success("Thank you! We've received your message and will get back to you soon.");
|
||||
form.reset();
|
||||
setTopicKey(null);
|
||||
setTopicError(null);
|
||||
} catch (err: unknown) {
|
||||
const messageErr = err instanceof Error ? err.message : "Something went wrong. Please try again.";
|
||||
toast.danger(messageErr);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
|
||||
<TextField
|
||||
isRequired
|
||||
name="fullName"
|
||||
maxLength={MAX_NAME}
|
||||
className="flex flex-col gap-1"
|
||||
aria-required="true"
|
||||
>
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Full name <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="First and last name"
|
||||
className="bg-[var(--surface)]"
|
||||
autoComplete="name"
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
/>
|
||||
<FieldError className="text-xs text-danger" />
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
isRequired
|
||||
name="email"
|
||||
type="email"
|
||||
maxLength={MAX_EMAIL}
|
||||
validate={(val: string) => {
|
||||
if (val && !EMAIL_REGEX.test(val)) return "Please enter a valid email address.";
|
||||
}}
|
||||
className="flex flex-col gap-1"
|
||||
aria-required="true"
|
||||
>
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Work email address <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="me@company.com"
|
||||
className="bg-[var(--surface)]"
|
||||
autoComplete="email"
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
/>
|
||||
<FieldError className="text-xs text-danger" />
|
||||
</TextField>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Topic <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
aria-label="Select a topic"
|
||||
aria-required="true"
|
||||
placeholder="Select a topic"
|
||||
value={topicKey}
|
||||
onChange={(value) => {
|
||||
setTopicKey(value ?? null);
|
||||
setTopicError(null);
|
||||
}}
|
||||
isDisabled={isSubmitting}
|
||||
className="w-full"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
<Select.Indicator />
|
||||
</Select.Trigger>
|
||||
<Select.Popover className="rounded-lg">
|
||||
<ListBox>
|
||||
{TOPIC_OPTIONS.map((opt) => (
|
||||
<ListBox.Item key={opt.key} id={opt.key} textValue={opt.label}>
|
||||
{opt.label}
|
||||
<ListBox.ItemIndicator />
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
{topicError && (
|
||||
<p className="text-xs text-danger mt-1" role="alert">
|
||||
{topicError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
isRequired
|
||||
name="message"
|
||||
maxLength={MAX_MESSAGE}
|
||||
className="flex flex-col gap-1"
|
||||
aria-required="true"
|
||||
>
|
||||
<Label className="text-sm font-medium text-[var(--foreground)]">
|
||||
Your message <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<TextArea
|
||||
rows={5}
|
||||
placeholder="Write your message"
|
||||
className="bg-[var(--surface)]"
|
||||
disabled={isSubmitting}
|
||||
aria-required="true"
|
||||
/>
|
||||
<FieldError className="text-xs text-danger" />
|
||||
</TextField>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isPending={isSubmitting}
|
||||
className="bg-[#f4a13a] text-[#1a2e2d] font-medium w-full md:w-auto md:self-start mt-1"
|
||||
aria-busy={isSubmitting}
|
||||
>
|
||||
{({ isPending }: { isPending: boolean }) =>
|
||||
isPending ? (
|
||||
<>
|
||||
<Spinner color="current" size="sm" />
|
||||
Submitting…
|
||||
</>
|
||||
) : (
|
||||
"Submit"
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
55
apps/storefront/src/components/layout/AnnouncementBar.tsx
Normal file
55
apps/storefront/src/components/layout/AnnouncementBar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const PROMOS = [
|
||||
{
|
||||
id: "free-shipping",
|
||||
message: "Free delivery on orders over £40. Automatically applied at checkout.",
|
||||
href: "/shop",
|
||||
},
|
||||
{
|
||||
id: "first-order",
|
||||
message: "Sign up to our newsletter to get 10% off your first order.",
|
||||
href: "/#newsletter",
|
||||
},
|
||||
{
|
||||
id: "reorders",
|
||||
message: "5% off on re-orders over £30. Automatically applied at checkout.",
|
||||
href: "/shop",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ROTATION_INTERVAL_MS = 6000;
|
||||
|
||||
export function AnnouncementBar() {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setIndex((i) => (i + 1) % PROMOS.length);
|
||||
}, ROTATION_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const promo = PROMOS[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full border-b border-[#e8e8e8] bg-[#f4a13a]"
|
||||
role="region"
|
||||
aria-label="Promotional offers"
|
||||
>
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-center px-4 py-2.5">
|
||||
<Link
|
||||
href={promo.href}
|
||||
className="text-center font-sans text-xs font-medium text-[#3d5554] transition-colors hover:text-[#236f6b] md:text-sm"
|
||||
>
|
||||
<span className="text-[#f2705a]">{promo.id === "free-shipping" ? "★ " : ""}</span>
|
||||
{promo.message}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,14 @@ interface BrandLogoProps {
|
||||
|
||||
export function BrandLogo({ size = 28, textClassName }: BrandLogoProps) {
|
||||
return (
|
||||
<Link href="/" className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex shrink-0 flex-row items-center gap-2"
|
||||
aria-label="The Pet Loft - Home"
|
||||
>
|
||||
<Image
|
||||
src="/branding/logo.svg"
|
||||
alt=""
|
||||
alt="The Pet Loft"
|
||||
width={size}
|
||||
height={size}
|
||||
className="shrink-0"
|
||||
|
||||
@@ -47,48 +47,15 @@ function TwitterIcon() {
|
||||
}
|
||||
|
||||
const shopLinks = [
|
||||
{ label: "All Products", href: "/shop" },
|
||||
{ label: "Dog Food", href: "/shop/dogs/dry-food" },
|
||||
{ label: "Cat Food", href: "/shop/cats/dry-food" },
|
||||
{ label: "Treats & Snacks", href: "/shop/dogs/treats" },
|
||||
{ label: "Toys", href: "/shop/dogs/toys" },
|
||||
{ label: "Beds & Baskets", href: "/shop/dogs/beds-and-baskets" },
|
||||
{ label: "Grooming & Care", href: "/shop/dogs/grooming-and-care" },
|
||||
{ label: "Leads & Collars", href: "/shop/dogs/leads-and-collars" },
|
||||
{ label: "Bowls & Feeders", href: "/shop/dogs/bowls-and-feeders" },
|
||||
{ label: "Flea & Worming", href: "/shop/dogs/flea-tick-and-worming-treatments" },
|
||||
{ label: "Clothing", href: "/shop/dogs/clothing" },
|
||||
{ label: "Pet Toys", href: "/shop/toys" },
|
||||
{ label: "Pet Treats", href: "/shop/treats" },
|
||||
{ label: "Cats Food", href: "/shop/cats/cat-dry-food" },
|
||||
{ label: "Dogs Food", href: "/shop/dogs/dog-dry-food" },
|
||||
{ label: "Cat Grooming & Care", href: "/shop/cats/cat-feliway-care" },
|
||||
{ label: "Dogs Grooming & Care", href: "/shop/dogs/dog-grooming-care" },
|
||||
];
|
||||
|
||||
const specialtyGroups = [
|
||||
{
|
||||
heading: "Brands",
|
||||
links: [
|
||||
{ label: "Almo Nature", href: "/brands/almo-nature" },
|
||||
{ label: "Applaws", href: "/brands/applaws" },
|
||||
{ label: "Arden Grange", href: "/brands/arden-grange" },
|
||||
{ label: "Shop All", href: "/shop" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Accessories",
|
||||
links: [
|
||||
{ label: "Crates & Travel", href: "/shop/dogs/crates-and-travel-accessories" },
|
||||
{ label: "Kennels & Gates", href: "/shop/dogs/kennels-flaps-and-gates" },
|
||||
{ label: "Trees & Scratching", href: "/shop/cats/trees-and-scratching-posts" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const engagementGroups = [
|
||||
{
|
||||
heading: "Community",
|
||||
links: [
|
||||
{ label: "Adopt a Pet", href: "/community/adopt" },
|
||||
{ label: "Pet Pharmacy", href: "/pharmacy" },
|
||||
{ label: "Pet Services", href: "/services" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Promotions",
|
||||
links: [
|
||||
@@ -99,30 +66,34 @@ const engagementGroups = [
|
||||
},
|
||||
];
|
||||
|
||||
const utilityGroups = [
|
||||
{
|
||||
heading: "Content",
|
||||
links: [
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: "Tips & Tricks", href: "/tips" },
|
||||
{ label: "Pet Guides", href: "/guides" },
|
||||
],
|
||||
},
|
||||
const engagementGroups = [
|
||||
{
|
||||
heading: "Support",
|
||||
links: [
|
||||
{ label: "Order Tracking", href: "/account/orders" },
|
||||
{ label: "Shipping Info", href: "/support/shipping" },
|
||||
{ label: "Shipping", href: "/support/shipping" },
|
||||
{ label: "Returns & Refunds", href: "/support/returns" },
|
||||
{ label: "Payment Security", href: "/support/payment-security" },
|
||||
{ label: "FAQs", href: "/support/faqs" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const utilityGroups = [
|
||||
// {
|
||||
// heading: "Content",
|
||||
// links: [
|
||||
// { label: "Blog", href: "/blog" },
|
||||
// { label: "Tips & Tricks", href: "/tips" },
|
||||
// { label: "Pet Guides", href: "/guides" },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
heading: "Company",
|
||||
links: [
|
||||
{ label: "About Us", href: "/about" },
|
||||
{ label: "Contact Us", href: "/contact" },
|
||||
{ label: "Careers", href: "/careers" },
|
||||
// { label: "About Us", href: "/about" },
|
||||
{ label: "Contact Us", href: "/support/contact-us" },
|
||||
{ label: "General Terms and Conditions", href: "/legal/general-terms-and-conditions" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -163,10 +134,7 @@ export function Footer() {
|
||||
{/* Brand & Social */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<BrandLogo
|
||||
size={30}
|
||||
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
|
||||
/>
|
||||
<BrandLogo size={40} />
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#3d5554]">
|
||||
Your trusted partner for premium pet supplies. Healthy pets,
|
||||
happy homes — from nutrition to play, we've got it all.
|
||||
@@ -219,12 +187,6 @@ export function Footer() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href="/special-offers"
|
||||
className="mt-3 inline-block text-sm font-semibold text-[#f2705a] transition-colors hover:text-[#e05a42]"
|
||||
>
|
||||
Sale
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Column 2 — Specialty */}
|
||||
@@ -300,20 +262,26 @@ export function Footer() {
|
||||
© {new Date().getFullYear()} The Pet Loft. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href="/terms"
|
||||
<a
|
||||
href="/legal/return-and-refund-policy"
|
||||
className="text-xs text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
Terms of Use
|
||||
Return & Refund Policy
|
||||
</a>
|
||||
<a
|
||||
href="/privacy"
|
||||
href="/legal/terms-of-service"
|
||||
className="text-xs text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a
|
||||
href="/legal/privacy-policy"
|
||||
className="text-xs text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="/data-protection"
|
||||
href="/legal/data-protection"
|
||||
className="text-xs text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
Data Protection
|
||||
|
||||
@@ -46,7 +46,7 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
||||
className={
|
||||
isDesktop
|
||||
? "relative flex w-full max-w-2xl items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
|
||||
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] p-1.5 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
|
||||
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] py-3 px-3 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
|
||||
}
|
||||
>
|
||||
{/* Category picker */}
|
||||
@@ -144,7 +144,7 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
|
||||
className={
|
||||
isDesktop
|
||||
? "flex-1 bg-transparent py-3 pl-4 pr-5 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
|
||||
: "flex-1 border-none bg-transparent pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
|
||||
: "min-h-[44px] flex-1 border-none bg-transparent py-2 pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
|
||||
}
|
||||
role="combobox"
|
||||
aria-expanded={search.isOpen && search.showResults}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function CoreBrandBar() {
|
||||
<div className="w-full bg-white">
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-8 px-6 py-4">
|
||||
{/* Logo */}
|
||||
<BrandLogo size={32} />
|
||||
<BrandLogo size={56} />
|
||||
|
||||
{/* Search Bar */}
|
||||
<HeaderSearchBar variant="desktop" />
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { TopUtilityBar } from "./TopUtilityBar";
|
||||
import { CoreBrandBar } from "./CoreBrandBar";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
|
||||
export function DesktopHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full shadow-sm">
|
||||
<TopUtilityBar />
|
||||
<CoreBrandBar />
|
||||
<BottomNav />
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
|
||||
@@ -27,7 +28,7 @@ export function HeaderUserAction() {
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="group flex flex-col items-center gap-1"
|
||||
>
|
||||
@@ -48,7 +49,7 @@ export function HeaderUserAction() {
|
||||
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#236f6b]">
|
||||
Sign In
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
export function TopUtilityBar() {
|
||||
return (
|
||||
<div className="w-full bg-[#f5f5f5] border-b border-[#e8e8e8]">
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-1.5 text-xs">
|
||||
{/* Domain */}
|
||||
<span className="tracking-wide text-[#3d5554]">www.thepetloft.com</span>
|
||||
|
||||
{/* Promo */}
|
||||
<div className="flex items-center gap-1.5 font-medium text-[#f2705a]">
|
||||
<span><strong className="text-[13px]">☞ 10% </strong>
|
||||
off your first order</span>
|
||||
<span>★</span>
|
||||
<span><strong className="text-[13px]">☞ 5% </strong>
|
||||
off on all Re-orders over <strong>£30</strong></span>
|
||||
<span>★</span>
|
||||
<span>Free shipping on orders over <strong>£40</strong></span>
|
||||
</div>
|
||||
|
||||
{/* Utility links */}
|
||||
<div className="flex items-center gap-5 text-[#3d5554]">
|
||||
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||
<path d="M14.05 2a9 9 0 0 1 8 7.94" />
|
||||
<path d="M14.05 6A5 5 0 0 1 18 10" />
|
||||
</svg>
|
||||
<span>Contact</span>
|
||||
</button>
|
||||
|
||||
<div className="h-3 w-px bg-[#ccc]" />
|
||||
|
||||
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span>EN</span>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,10 +23,7 @@ export function MobileCoreBrandBar() {
|
||||
{/* Logo and Actions Row */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<BrandLogo
|
||||
size={26}
|
||||
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
|
||||
/>
|
||||
<BrandLogo size={44} />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { MobileUtilityBar } from "./MobileUtilityBar";
|
||||
import { MobileNavButtons } from "./MobileNavButtons";
|
||||
import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
|
||||
import Link from "next/link";
|
||||
@@ -22,14 +21,10 @@ export function MobileHeader() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* In-flow: utility bar + logo row scroll away with the page */}
|
||||
{/* In-flow: logo row scrolls away with the page */}
|
||||
<div className="w-full max-w-full min-w-0 overflow-x-hidden bg-white">
|
||||
<MobileUtilityBar />
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-[#e8f7f6] px-4 py-3">
|
||||
<BrandLogo
|
||||
size={26}
|
||||
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
|
||||
/>
|
||||
<BrandLogo size={44} />
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<Link
|
||||
href="/wishlist"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
|
||||
@@ -27,7 +28,7 @@ export function MobileHeaderUserAction() {
|
||||
|
||||
if (isLoading || !isAuthenticated) {
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb]"
|
||||
>
|
||||
@@ -45,7 +46,7 @@ export function MobileHeaderUserAction() {
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
const PROMO_TEXT =
|
||||
"☞ 10% off your first order ★ ☞ 5% off on all Re-orders over £30 ★ Free shipping on orders over £40 ★ ";
|
||||
|
||||
export function MobileUtilityBar() {
|
||||
return (
|
||||
<div className="relative flex min-h-[2.5rem] w-full max-w-full items-center overflow-hidden border-b border-[#e8e8e8] bg-[#f5f5f5]">
|
||||
<div className="absolute inset-0 flex items-center overflow-hidden" aria-hidden>
|
||||
<div className="flex animate-marquee whitespace-nowrap">
|
||||
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
|
||||
{PROMO_TEXT}
|
||||
</span>
|
||||
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
|
||||
{PROMO_TEXT}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
apps/storefront/src/components/legal/LegalDocPage.tsx
Normal file
98
apps/storefront/src/components/legal/LegalDocPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { getLegalDoc, type LegalSlug } from "@/lib/legal/getLegalDoc";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
const LEGAL_LINKS: { slug: LegalSlug; label: string }[] = [
|
||||
{ slug: "terms-of-service", label: "Terms of Service" },
|
||||
{ slug: "privacy-policy", label: "Privacy Policy" },
|
||||
{ slug: "data-protection", label: "Data Protection" },
|
||||
{ slug: "general-terms-and-conditions", label: "General Terms and Conditions" },
|
||||
{ slug: "return-and-refund-policy", label: "Return and Refund Policy" },
|
||||
];
|
||||
|
||||
const defaultTitles: Record<LegalSlug, string> = {
|
||||
"terms-of-service": "Terms of Service",
|
||||
"privacy-policy": "Privacy Policy",
|
||||
"data-protection": "Data Protection",
|
||||
"general-terms-and-conditions": "General Terms and Conditions",
|
||||
"return-and-refund-policy": "Return and Refund Policy",
|
||||
};
|
||||
|
||||
type LegalDocPageProps = {
|
||||
slug: LegalSlug;
|
||||
};
|
||||
|
||||
export function LegalDocPage({ slug }: LegalDocPageProps) {
|
||||
const doc = getLegalDoc(slug);
|
||||
if (!doc) notFound();
|
||||
|
||||
const title = doc.data.title ?? defaultTitles[slug];
|
||||
const others = LEGAL_LINKS.filter((l) => l.slug !== slug);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
← Back to Shop
|
||||
</Link>
|
||||
<article className="prose prose-[#1a2e2d] max-w-none">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{doc.data.lastUpdated && (
|
||||
<p className="mt-2 text-[#3d5554]">
|
||||
Last updated: {doc.data.lastUpdated}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 [&_h2]:font-[family-name:var(--font-fraunces)] [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[#236f6b] [&_h2]:mt-8 [&_h2]:first:mt-0 [&_p]:text-[#1a2e2d] [&_p]:leading-relaxed [&_p]:mb-3 [&_a]:font-medium [&_a]:text-[#38a99f] [&_a]:underline [&_a]:transition-colors [&_a:hover]:text-[#236f6b] [&_ol]:mb-3 [&_ol]:list-inside [&_ol]:list-decimal [&_ul]:mb-3 [&_ul]:list-inside [&_ul]:list-disc">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{doc.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-2 border-t border-[#236f6b]/20 pt-6">
|
||||
{others.length > 0 && (
|
||||
<p className="text-sm text-[#3d5554]">
|
||||
Other policies:{" "}
|
||||
{others.map((l, i) => (
|
||||
<span key={l.slug}>
|
||||
{i > 0 && " · "}
|
||||
<Link
|
||||
href={`/${l.slug}`}
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-[#3d5554]">
|
||||
Need help?{" "}
|
||||
<Link
|
||||
href="/contact"
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../../../../convex/_generated/api";
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel";
|
||||
import { ReviewSortOption } from "@/lib/product-detail/types";
|
||||
import type { ProductDetailReview, ReviewSortOption } from "@/lib/product-detail/types";
|
||||
import { ReviewSortBar } from "./ReviewSortBar";
|
||||
import { ReviewList } from "./ReviewList";
|
||||
import { ReviewForm } from "./ReviewForm";
|
||||
@@ -27,7 +27,7 @@ export function ProductDetailReviewsPanel({ productId, initialRating, initialRev
|
||||
productId: productId as Id<"products">,
|
||||
sortBy,
|
||||
limit: offset + LIMIT,
|
||||
offset: 0, // In this pattern, we increase limit to fetch more pages without resetting offset, so previously fetched array grows
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
if (result === undefined) return <ProductDetailReviewsSkeleton />;
|
||||
@@ -58,7 +58,7 @@ export function ProductDetailReviewsPanel({ productId, initialRating, initialRev
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
<ReviewList
|
||||
reviews={page as any}
|
||||
reviews={page as ProductDetailReview[]}
|
||||
total={total}
|
||||
hasMore={hasMore}
|
||||
isLoading={result === undefined}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
type ProductDetailDescriptionSectionProps = {
|
||||
/** Product description (HTML or plain text); server-rendered in initial HTML per SEO. */
|
||||
/** Product description (Markdown); server-rendered in initial HTML per SEO. */
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
const descriptionClasses =
|
||||
"max-w-3xl text-sm leading-relaxed text-[var(--foreground)]/80 [&_ol]:mb-3 [&_ol]:list-inside [&_ol]:list-decimal [&_p:last-child]:mb-0 [&_p]:mb-3 [&_ul]:mb-3 [&_ul]:list-inside [&_ul]:list-disc";
|
||||
|
||||
/**
|
||||
* Description content for the PDP tabs section.
|
||||
* Renders inside a parent <section> provided by ProductDetailTabsSection.
|
||||
* Renders Markdown as HTML inside a parent <section> provided by ProductDetailTabsSection.
|
||||
* If empty, shows a short fallback message.
|
||||
*/
|
||||
export function ProductDetailDescriptionSection({
|
||||
@@ -15,10 +20,9 @@ export function ProductDetailDescriptionSection({
|
||||
typeof description === "string" && description.trim().length > 0;
|
||||
|
||||
return hasContent ? (
|
||||
<div
|
||||
className="max-w-3xl text-sm leading-relaxed text-[var(--foreground)]/80 [&_ol]:mb-3 [&_ol]:list-inside [&_ol]:list-decimal [&_p:last-child]:mb-0 [&_p]:mb-3 [&_ul]:mb-3 [&_ul]:list-inside [&_ul]:list-disc"
|
||||
dangerouslySetInnerHTML={{ __html: description!.trim() }}
|
||||
/>
|
||||
<div className={descriptionClasses}>
|
||||
<ReactMarkdown>{description!.trim()}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--muted)]">No description available.</p>
|
||||
);
|
||||
|
||||
@@ -206,7 +206,10 @@ export function ProductDetailHeroSection({
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
const images: ProductDetailImage[] = product.images ?? [];
|
||||
const variants: ProductDetailVariant[] = product.variants ?? [];
|
||||
const variants = useMemo(
|
||||
() => (product.variants ?? []) as ProductDetailVariant[],
|
||||
[product.variants],
|
||||
);
|
||||
const mainImage = images[selectedImageIndex] ?? images[0];
|
||||
const dimensions = useMemo(
|
||||
() => getAttributeDimensions(variants),
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function ProductDetailHeroSectionWrapper({
|
||||
|
||||
return (
|
||||
<ProductDetailHeroSection
|
||||
product={product as any}
|
||||
product={product}
|
||||
category={category}
|
||||
subCategory={subCategory}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Suspense } from "react";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "../../../../../../convex/_generated/api";
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Customer confidence booster: trust badges (Free Shipping, Easy Returns, Secure Checkout).
|
||||
* Rendered below product grids on shop pages and homepage. PetPaws theme, mobile-first.
|
||||
* Each badge links to its corresponding policy page.
|
||||
*/
|
||||
export function CustomerConfidenceBooster() {
|
||||
const items: { title: string; subheading: string; icon: React.ReactNode }[] = [
|
||||
export function TrustAndCredibilitySection() {
|
||||
const items: {
|
||||
title: string;
|
||||
subheading: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
title: "Free Shipping",
|
||||
subheading: "No extra costs (T&C apply)",
|
||||
href: "/support/shipping",
|
||||
icon: (
|
||||
<svg
|
||||
className="size-8 shrink-0"
|
||||
@@ -33,6 +42,7 @@ export function CustomerConfidenceBooster() {
|
||||
{
|
||||
title: "Easy Returns",
|
||||
subheading: "Return with ease",
|
||||
href: "/support/returns",
|
||||
icon: (
|
||||
<svg
|
||||
className="size-8 shrink-0"
|
||||
@@ -55,6 +65,7 @@ export function CustomerConfidenceBooster() {
|
||||
{
|
||||
title: "Secure Checkout",
|
||||
subheading: "Secure payment",
|
||||
href: "/support/payment-security",
|
||||
icon: (
|
||||
<svg
|
||||
className="size-8 shrink-0"
|
||||
@@ -75,31 +86,28 @@ export function CustomerConfidenceBooster() {
|
||||
return (
|
||||
<section
|
||||
aria-label="Why shop with us"
|
||||
className="w-full border border-[#d9e8e7] bg-gradient-to-r from-[#e8f7f6] to-[#f0f8f7] px-4 py-6 md:px-6 md:py-8 m-16 max-w-7xl mx-auto rounded-xl"
|
||||
className="w-full border border-[#f4a13a] bg-gradient-to-r from-[#e8f7f6] to-[#f0f8f7] px-4 py-6 md:px-6 md:py-8 m-16 max-w-7xl mx-auto rounded-xl"
|
||||
>
|
||||
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 md:gap-8">
|
||||
{items.map(({ title, subheading, icon }) => (
|
||||
<div
|
||||
{items.map(({ title, subheading, href, icon }) => (
|
||||
<Link
|
||||
key={title}
|
||||
className="flex flex-col items-center gap-3 text-center"
|
||||
href={href}
|
||||
className="flex flex-col items-center gap-3 text-center transition-colors hover:text-[#236f6b] focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2 focus:ring-offset-[#e8f7f6] rounded-lg"
|
||||
>
|
||||
<div
|
||||
className="flex size-12 items-center justify-center rounded-full bg-white text-[#236f6b] shadow-sm ring-1 ring-[#d9e8e7]"
|
||||
className="flex size-12 items-center justify-center rounded-full bg-white text-[#236f6b] shadow-sm ring-1 ring-[#d9e8e7] transition-colors"
|
||||
aria-hidden
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className="block font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg"
|
||||
>
|
||||
<span className="block font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg">
|
||||
{title}
|
||||
</span>
|
||||
<span className="block text-sm text-[#3d5554]">
|
||||
{subheading}
|
||||
</span>
|
||||
<span className="block text-sm text-[#3d5554]">{subheading}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -44,25 +44,56 @@ export function CtaSection() {
|
||||
className="mt-1 font-[family-name:var(--font-fraunces)] text-4xl font-bold leading-tight tracking-tight text-[var(--foreground)] drop-shadow-sm md:text-5xl lg:text-6xl lg:leading-tight"
|
||||
>
|
||||
<span className="relative inline-block border-b-4 border-[var(--warm)] pb-1">
|
||||
45% OFF
|
||||
25% OFF
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mt-3 font-sans text-base text-[var(--foreground)] md:text-lg">
|
||||
Thousands of pet favourites
|
||||
Thousands of pet essentials
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mt-6 inline-flex w-fit items-center gap-1 rounded-full bg-[var(--warm)] px-6 py-3 font-sans text-sm font-medium text-[var(--neutral-900)] shadow-sm transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[var(--brand)] focus:ring-offset-2"
|
||||
className="mt-6 inline-flex min-h-[48px] w-fit items-center justify-center gap-2 rounded-full bg-[#e89120] px-6 py-3 font-sans text-base font-semibold text-white shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#d97f0f] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2 md:px-8 md:py-4"
|
||||
>
|
||||
Shop Pet Deals
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)] text-sm" aria-hidden>
|
||||
|
||||
<span>Healthy pets, happy homes.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
{/* Doggy CTA */}
|
||||
<section
|
||||
aria-labelledby="cta-doggy"
|
||||
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
|
||||
>
|
||||
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
|
||||
<div className="relative z-10">
|
||||
<h2
|
||||
id="cta-doggy"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-2xl"
|
||||
>
|
||||
Shop Dog Essentials
|
||||
</h2>
|
||||
<Link
|
||||
href="/shop/dogs"
|
||||
className="mt-3 inline-flex min-h-[44px] w-fit items-center justify-center gap-1.5 rounded-full bg-[#f4a13a] px-4 py-2.5 font-sans text-sm font-bold text-[#1a2e2d] shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#f5ad4d] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:min-h-[48px] md:px-8 md:py-4 md:text-base"
|
||||
>
|
||||
Shop Now
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)]" aria-hidden>
|
||||
<span>—</span>
|
||||
<span>—</span>
|
||||
<span>—</span>
|
||||
<span className="opacity-60">…</span>
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={CTA_IMAGES.kitty}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -75,18 +106,15 @@ export function CtaSection() {
|
||||
<div className="relative z-10">
|
||||
<h2
|
||||
id="cta-kitty"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-2xl"
|
||||
>
|
||||
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
|
||||
<span>Kitty</span>
|
||||
<br />
|
||||
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
|
||||
Shop Cat Essentials
|
||||
</h2>
|
||||
<Link
|
||||
href="/shop/cats"
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
|
||||
className="mt-3 inline-flex min-h-[44px] w-fit items-center justify-center gap-1.5 rounded-full bg-[#f4a13a] px-4 py-2.5 font-sans text-sm font-bold text-[#1a2e2d] shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#f5ad4d] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:min-h-[48px] md:px-8 md:py-4 md:text-base"
|
||||
>
|
||||
Shop here
|
||||
Shop Now
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -101,40 +129,7 @@ export function CtaSection() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Doggy CTA */}
|
||||
<section
|
||||
aria-labelledby="cta-doggy"
|
||||
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
|
||||
>
|
||||
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
|
||||
<div className="relative z-10">
|
||||
<h2
|
||||
id="cta-doggy"
|
||||
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
|
||||
>
|
||||
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
|
||||
<span>Doggy</span>
|
||||
<br />
|
||||
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
|
||||
</h2>
|
||||
<Link
|
||||
href="/shop/dogs"
|
||||
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
|
||||
>
|
||||
Shop here
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={CTA_IMAGES.kitty}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@ function EnvelopeIcon({ className }: { className?: string }) {
|
||||
export function NewsletterSection() {
|
||||
return (
|
||||
<section
|
||||
id="newsletter"
|
||||
aria-label="Newsletter signup"
|
||||
className="relative max-w-7xl mx-auto w-full px-4 md:px-8 mb-12"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
@@ -194,7 +194,7 @@ export function PetCategoryPage({ slug }: { slug: PetCategorySlug }) {
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
|
||||
import {
|
||||
filterStateFromSearchParams,
|
||||
mergeFilterStateIntoSearchParams,
|
||||
@@ -164,7 +164,7 @@ export function RecentlyAddedPage() {
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,16 +22,6 @@ type ShopFilterContentProps = {
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
const FILTER_SECTION_IDS = [
|
||||
"brand",
|
||||
"tags",
|
||||
"petSize",
|
||||
"ageRange",
|
||||
"specialDiet",
|
||||
"material",
|
||||
"flavor",
|
||||
] as const;
|
||||
|
||||
export function ShopFilterContent({
|
||||
options,
|
||||
value,
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { ShopFilterState } from "@/lib/shop/filterState";
|
||||
* otherwise renders children (e.g. sub-category links).
|
||||
*/
|
||||
export function ShopFilterSidebar({
|
||||
children,
|
||||
className,
|
||||
filterOptions,
|
||||
filterState,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
|
||||
import {
|
||||
filterStateFromSearchParams,
|
||||
filterStateToSearchParams,
|
||||
@@ -164,7 +164,7 @@ export function ShopIndexContent() {
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ export function ShopToolbar({
|
||||
currentSort,
|
||||
onSortChange,
|
||||
onOpenFilter,
|
||||
resultCount,
|
||||
}: {
|
||||
sortOptions: SortOption[];
|
||||
currentSort: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
|
||||
import {
|
||||
filterStateFromSearchParams,
|
||||
filterStateToSearchParams,
|
||||
@@ -211,7 +211,7 @@ export function SubCategoryPageContent({
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
|
||||
import {
|
||||
filterStateFromSearchParams,
|
||||
mergeFilterStateIntoSearchParams,
|
||||
@@ -170,7 +170,7 @@ export function TagShopPage({
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
|
||||
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
|
||||
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
|
||||
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
|
||||
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
|
||||
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
|
||||
import {
|
||||
PET_CATEGORY_SLUGS,
|
||||
TOP_CATEGORY_SLUGS,
|
||||
@@ -249,7 +249,7 @@ export function TopCategoryPage({ slug }: { slug: TopCategorySlug }) {
|
||||
/>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<CustomerConfidenceBooster />
|
||||
<TrustAndCredibilitySection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
132
apps/storefront/src/components/support/FaqPageView.tsx
Normal file
132
apps/storefront/src/components/support/FaqPageView.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { FaqSection } from "@/lib/faq/getFaqSections";
|
||||
|
||||
type FaqPageViewProps = {
|
||||
sections: FaqSection[];
|
||||
lastUpdated?: string;
|
||||
};
|
||||
|
||||
function FaqAnswer({ content }: { content: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) =>
|
||||
href?.startsWith("/") ? (
|
||||
<Link
|
||||
href={href}
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={href}
|
||||
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function FaqPageView({ sections, lastUpdated }: FaqPageViewProps) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selectedSection = useMemo(
|
||||
() => (selectedId ? sections.find((s) => s.title === selectedId) ?? null : null),
|
||||
[sections, selectedId]
|
||||
);
|
||||
|
||||
const sectionId = (title: string) => title.replace(/\s+/g, "-").toLowerCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sections.map((section) => {
|
||||
const id = sectionId(section.title);
|
||||
const isSelected = selectedId === section.title;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(isSelected ? null : section.title)}
|
||||
className="flex flex-col items-start rounded-lg border-2 border-[#236f6b]/20 bg-[#f0f8f7] p-5 text-left transition-colors hover:border-[#38a99f] hover:bg-[#e8f7f6] focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2"
|
||||
aria-expanded={isSelected}
|
||||
data-selected={isSelected ? "" : undefined}
|
||||
style={
|
||||
isSelected
|
||||
? { borderColor: "#236f6b", backgroundColor: "#e8f7f6" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#1a2e2d]">
|
||||
{section.title}
|
||||
</span>
|
||||
{section.subtitle && (
|
||||
<span className="mt-1 text-sm text-[#3d5554]">
|
||||
{section.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedSection && selectedSection.items.length > 0 && (
|
||||
<section
|
||||
id={`faq-${sectionId(selectedSection.title)}`}
|
||||
aria-labelledby={`faq-heading-${sectionId(selectedSection.title)}`}
|
||||
className="rounded-lg border border-[#236f6b]/20 bg-white p-4 md:p-6"
|
||||
>
|
||||
<h2
|
||||
id={`faq-heading-${sectionId(selectedSection.title)}`}
|
||||
className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#236f6b]"
|
||||
>
|
||||
{selectedSection.title}
|
||||
</h2>
|
||||
<Accordion
|
||||
allowsMultipleExpanded
|
||||
className="mt-4 w-full"
|
||||
hideSeparator={false}
|
||||
>
|
||||
{selectedSection.items.map((item, index) => (
|
||||
<Accordion.Item
|
||||
key={`${sectionId(selectedSection.title)}-${index}`}
|
||||
id={`${sectionId(selectedSection.title)}-q-${index}`}
|
||||
>
|
||||
<Accordion.Heading>
|
||||
<Accordion.Trigger className="text-left text-sm font-medium text-[#1a2e2d]">
|
||||
{item.question}
|
||||
<Accordion.Indicator />
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Heading>
|
||||
<Accordion.Panel>
|
||||
<Accordion.Body className="pb-4 pt-1 text-[#1a2e2d] leading-relaxed [&_a]:font-medium [&_a]:text-[#38a99f] [&_a]:underline [&_a:hover]:text-[#236f6b]">
|
||||
<FaqAnswer content={item.answer} />
|
||||
</Accordion.Body>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-sm text-[#3d5554]">Last updated: {lastUpdated}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import { WishlistSignInPrompt } from "./state/WishlistSignInPrompt";
|
||||
export function WishlistPageView() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useConvexAuth();
|
||||
const { items, isLoading, isEmpty } = useWishlist();
|
||||
const { removeItem, isRemoving, addToCart, isAddingToCart } =
|
||||
useWishlistMutations();
|
||||
const { removeItem, isRemoving, addToCart } = useWishlistMutations();
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<WishlistItem | null>(null);
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { WISHLIST_PATH } from "@/lib/wishlist/constants";
|
||||
|
||||
function HeartIcon() {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMutation } from "convex/react";
|
||||
import { useCallback } from "react";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel";
|
||||
import type { AddressFormData } from "./types";
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,7 @@ export function useAddressMutations(): {
|
||||
data: Partial<AddressFormData> & { isValidated?: boolean },
|
||||
): Promise<void> => {
|
||||
await updateMutation({
|
||||
id: id as any,
|
||||
id: id as Id<"addresses">,
|
||||
...(data.firstName !== undefined && { firstName: data.firstName }),
|
||||
...(data.lastName !== undefined && { lastName: data.lastName }),
|
||||
...(data.phone !== undefined && { phone: data.phone }),
|
||||
@@ -69,14 +70,14 @@ export function useAddressMutations(): {
|
||||
|
||||
const setDefault = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await setDefaultMutation({ id: id as any });
|
||||
await setDefaultMutation({ id: id as Id<"addresses"> });
|
||||
},
|
||||
[setDefaultMutation],
|
||||
);
|
||||
|
||||
const markValidated = useCallback(
|
||||
async (id: string, isValidated: boolean): Promise<void> => {
|
||||
await markValidatedMutation({ id: id as any, isValidated });
|
||||
await markValidatedMutation({ id: id as Id<"addresses">, isValidated });
|
||||
},
|
||||
[markValidatedMutation],
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("useShippingRate", () => {
|
||||
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.error).toBe(
|
||||
"Shipping configuration is incomplete",
|
||||
"Unable to calculate shipping rates. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,7 +140,9 @@ describe("useShippingRate", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("Network timeout");
|
||||
expect(result.current.error).toBe(
|
||||
"Unable to calculate shipping rates. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
mockActionFn.mockResolvedValue(sampleResult);
|
||||
|
||||
78
apps/storefront/src/lib/faq/getFaqSections.ts
Normal file
78
apps/storefront/src/lib/faq/getFaqSections.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import fs from "fs";
|
||||
import matter from "gray-matter";
|
||||
import path from "path";
|
||||
|
||||
export type FaqItem = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
export type FaqSection = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
order: number;
|
||||
items: FaqItem[];
|
||||
};
|
||||
|
||||
function getFaqContentDir(): string {
|
||||
const cwd = process.cwd();
|
||||
const direct = path.join(cwd, "content", "faq");
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
const fromStorefront = path.join(cwd, "apps", "storefront", "content", "faq");
|
||||
if (fs.existsSync(fromStorefront)) return fromStorefront;
|
||||
return direct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses FAQ markdown body: each "### Question" heading starts a new Q&A;
|
||||
* the following lines (until the next ### or EOF) are the answer.
|
||||
*/
|
||||
function parseFaqBody(body: string): FaqItem[] {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const blocks = trimmed.split(/\n(?=### )/);
|
||||
const items: FaqItem[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const firstNewline = block.indexOf("\n");
|
||||
const firstLine = firstNewline === -1 ? block : block.slice(0, firstNewline);
|
||||
const rest = firstNewline === -1 ? "" : block.slice(firstNewline + 1).trim();
|
||||
const question = firstLine.replace(/^###\s*/, "").trim();
|
||||
if (question) {
|
||||
items.push({ question, answer: rest });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses all FAQ section markdown files. Server-only; use in Server Components.
|
||||
* Returns sections sorted by frontmatter `order`.
|
||||
*/
|
||||
export function getFaqSections(): FaqSection[] {
|
||||
const contentDir = getFaqContentDir();
|
||||
if (!fs.existsSync(contentDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".md"));
|
||||
const sections: FaqSection[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(contentDir, file);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const { data, content } = matter(raw);
|
||||
const title = (data.title as string) ?? path.basename(file, ".md");
|
||||
const subtitle = (data.subtitle as string) ?? "";
|
||||
const order = typeof data.order === "number" ? data.order : 999;
|
||||
const items = parseFaqBody(content);
|
||||
sections.push({ title, subtitle, order, items });
|
||||
} catch {
|
||||
// skip invalid files
|
||||
}
|
||||
}
|
||||
|
||||
sections.sort((a, b) => a.order - b.order);
|
||||
return sections;
|
||||
}
|
||||
58
apps/storefront/src/lib/legal/getLegalDoc.ts
Normal file
58
apps/storefront/src/lib/legal/getLegalDoc.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from "fs";
|
||||
import matter from "gray-matter";
|
||||
import path from "path";
|
||||
|
||||
export const LEGAL_SLUGS = [
|
||||
"return-and-refund-policy",
|
||||
"terms-of-service",
|
||||
"privacy-policy",
|
||||
"data-protection",
|
||||
"general-terms-and-conditions",
|
||||
] as const;
|
||||
|
||||
export type LegalSlug = (typeof LEGAL_SLUGS)[number];
|
||||
|
||||
export type LegalDocData = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
};
|
||||
|
||||
export type LegalDoc = {
|
||||
data: LegalDocData;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function getContentDir(): string {
|
||||
const cwd = process.cwd();
|
||||
const direct = path.join(cwd, "content", "legal");
|
||||
if (fs.existsSync(direct)) return direct;
|
||||
const fromRoot = path.join(cwd, "apps", "storefront", "content", "legal");
|
||||
if (fs.existsSync(fromRoot)) return fromRoot;
|
||||
return direct;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a legal markdown file. Server-only; use in Server Components.
|
||||
* Returns null if slug is invalid or file is missing (call notFound() in the page).
|
||||
*/
|
||||
export function getLegalDoc(slug: string): LegalDoc | null {
|
||||
if (!LEGAL_SLUGS.includes(slug as LegalSlug)) return null;
|
||||
const contentDir = getContentDir();
|
||||
const filePath = path.join(contentDir, `${slug}.md`);
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const { data, content } = matter(raw);
|
||||
return {
|
||||
data: {
|
||||
title: data.title as string | undefined,
|
||||
description: data.description as string | undefined,
|
||||
lastUpdated: data.lastUpdated as string | undefined,
|
||||
},
|
||||
content: content.trim(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ describe("useClickOutside", () => {
|
||||
|
||||
it("does not call handler when enabled is false", () => {
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => {
|
||||
renderHook(() => {
|
||||
const ref = useRef<HTMLDivElement>(document.createElement("div"));
|
||||
useClickOutside([ref], handler, false);
|
||||
return ref;
|
||||
|
||||
@@ -5,8 +5,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useCartSession } from "./useCartSession";
|
||||
|
||||
const mockUseAuth = vi.fn();
|
||||
vi.mock("@clerk/nextjs", () => ({ useAuth: () => mockUseAuth() }));
|
||||
const mockUseConvexAuth = vi.fn();
|
||||
vi.mock("convex/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("convex/react")>();
|
||||
return {
|
||||
...actual,
|
||||
useConvexAuth: () => mockUseConvexAuth(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetGuestSessionId = vi.fn();
|
||||
const mockSetGuestSessionCookie = vi.fn();
|
||||
@@ -21,17 +27,18 @@ describe("useCartSession", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGenerateGuestSessionId.mockReturnValue("generated-uuid-123");
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: false, isAuthenticated: false });
|
||||
});
|
||||
|
||||
it("returns authenticated session when loaded and signed in", () => {
|
||||
mockUseAuth.mockReturnValue({ isLoaded: true, isSignedIn: true });
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: false, isAuthenticated: true });
|
||||
const { result } = renderHook(() => useCartSession());
|
||||
expect(result.current).toEqual({ sessionId: undefined, isGuest: false });
|
||||
expect(mockGetGuestSessionId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns guest session with cookie value when not signed in and cookie present", () => {
|
||||
mockUseAuth.mockReturnValue({ isLoaded: true, isSignedIn: false });
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: false, isAuthenticated: false });
|
||||
mockGetGuestSessionId.mockReturnValue("cookie-uuid-456");
|
||||
const { result } = renderHook(() => useCartSession());
|
||||
expect(result.current).toEqual({
|
||||
@@ -44,7 +51,7 @@ describe("useCartSession", () => {
|
||||
});
|
||||
|
||||
it("returns guest session and sets cookie when not signed in and cookie missing", () => {
|
||||
mockUseAuth.mockReturnValue({ isLoaded: true, isSignedIn: false });
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: false, isAuthenticated: false });
|
||||
mockGetGuestSessionId.mockReturnValue(null);
|
||||
const { result } = renderHook(() => useCartSession());
|
||||
expect(result.current).toEqual({
|
||||
@@ -57,7 +64,7 @@ describe("useCartSession", () => {
|
||||
});
|
||||
|
||||
it("treats not-loaded auth as guest and uses cookie when present", () => {
|
||||
mockUseAuth.mockReturnValue({ isLoaded: false, isSignedIn: false });
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: true, isAuthenticated: false });
|
||||
mockGetGuestSessionId.mockReturnValue("existing-guest-id");
|
||||
const { result } = renderHook(() => useCartSession());
|
||||
expect(result.current).toEqual({
|
||||
@@ -68,7 +75,7 @@ describe("useCartSession", () => {
|
||||
});
|
||||
|
||||
it("treats not-loaded auth as guest and creates session when cookie missing", () => {
|
||||
mockUseAuth.mockReturnValue({ isLoaded: false, isSignedIn: false });
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: true, isAuthenticated: false });
|
||||
mockGetGuestSessionId.mockReturnValue(null);
|
||||
const { result } = renderHook(() => useCartSession());
|
||||
expect(result.current).toEqual({
|
||||
@@ -79,7 +86,7 @@ describe("useCartSession", () => {
|
||||
});
|
||||
|
||||
it("returns authenticated only when both isLoaded and isSignedIn are true", () => {
|
||||
mockUseAuth.mockReturnValue({ isLoaded: true, isSignedIn: false });
|
||||
mockUseConvexAuth.mockReturnValue({ isLoading: false, isAuthenticated: false });
|
||||
mockGetGuestSessionId.mockReturnValue("guest-id");
|
||||
const { result } = renderHook(() => useCartSession());
|
||||
expect(result.current.isGuest).toBe(true);
|
||||
|
||||
249
convex/_generated/api.d.ts
vendored
Normal file
249
convex/_generated/api.d.ts
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as addresses from "../addresses.js";
|
||||
import type * as adminInvitations from "../adminInvitations.js";
|
||||
import type * as carts from "../carts.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as checkout from "../checkout.js";
|
||||
import type * as checkoutActions from "../checkoutActions.js";
|
||||
import type * as emails from "../emails.js";
|
||||
import type * as fulfillmentActions from "../fulfillmentActions.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as messages from "../messages.js";
|
||||
import type * as model_carts from "../model/carts.js";
|
||||
import type * as model_categories from "../model/categories.js";
|
||||
import type * as model_checkout from "../model/checkout.js";
|
||||
import type * as model_orders from "../model/orders.js";
|
||||
import type * as model_products from "../model/products.js";
|
||||
import type * as model_shippo from "../model/shippo.js";
|
||||
import type * as model_stripe from "../model/stripe.js";
|
||||
import type * as model_users from "../model/users.js";
|
||||
import type * as orders from "../orders.js";
|
||||
import type * as products from "../products.js";
|
||||
import type * as returnActions from "../returnActions.js";
|
||||
import type * as reviews from "../reviews.js";
|
||||
import type * as shippoWebhook from "../shippoWebhook.js";
|
||||
import type * as stripeActions from "../stripeActions.js";
|
||||
import type * as users from "../users.js";
|
||||
import type * as wishlists from "../wishlists.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
addresses: typeof addresses;
|
||||
adminInvitations: typeof adminInvitations;
|
||||
carts: typeof carts;
|
||||
categories: typeof categories;
|
||||
checkout: typeof checkout;
|
||||
checkoutActions: typeof checkoutActions;
|
||||
emails: typeof emails;
|
||||
fulfillmentActions: typeof fulfillmentActions;
|
||||
http: typeof http;
|
||||
messages: typeof messages;
|
||||
"model/carts": typeof model_carts;
|
||||
"model/categories": typeof model_categories;
|
||||
"model/checkout": typeof model_checkout;
|
||||
"model/orders": typeof model_orders;
|
||||
"model/products": typeof model_products;
|
||||
"model/shippo": typeof model_shippo;
|
||||
"model/stripe": typeof model_stripe;
|
||||
"model/users": typeof model_users;
|
||||
orders: typeof orders;
|
||||
products: typeof products;
|
||||
returnActions: typeof returnActions;
|
||||
reviews: typeof reviews;
|
||||
shippoWebhook: typeof shippoWebhook;
|
||||
stripeActions: typeof stripeActions;
|
||||
users: typeof users;
|
||||
wishlists: typeof wishlists;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's public API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's internal API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = internal.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {
|
||||
resend: {
|
||||
lib: {
|
||||
cancelEmail: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ emailId: string },
|
||||
null
|
||||
>;
|
||||
cleanupAbandonedEmails: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ olderThan?: number },
|
||||
null
|
||||
>;
|
||||
cleanupOldEmails: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ olderThan?: number },
|
||||
null
|
||||
>;
|
||||
createManualEmail: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{
|
||||
from: string;
|
||||
headers?: Array<{ name: string; value: string }>;
|
||||
replyTo?: Array<string>;
|
||||
subject: string;
|
||||
to: Array<string> | string;
|
||||
},
|
||||
string
|
||||
>;
|
||||
get: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
{ emailId: string },
|
||||
{
|
||||
bcc?: Array<string>;
|
||||
bounced?: boolean;
|
||||
cc?: Array<string>;
|
||||
clicked?: boolean;
|
||||
complained: boolean;
|
||||
createdAt: number;
|
||||
deliveryDelayed?: boolean;
|
||||
errorMessage?: string;
|
||||
failed?: boolean;
|
||||
finalizedAt: number;
|
||||
from: string;
|
||||
headers?: Array<{ name: string; value: string }>;
|
||||
html?: string;
|
||||
opened: boolean;
|
||||
replyTo: Array<string>;
|
||||
resendId?: string;
|
||||
segment: number;
|
||||
status:
|
||||
| "waiting"
|
||||
| "queued"
|
||||
| "cancelled"
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "delivery_delayed"
|
||||
| "bounced"
|
||||
| "failed";
|
||||
subject?: string;
|
||||
template?: {
|
||||
id: string;
|
||||
variables?: Record<string, string | number>;
|
||||
};
|
||||
text?: string;
|
||||
to: Array<string>;
|
||||
} | null
|
||||
>;
|
||||
getStatus: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
{ emailId: string },
|
||||
{
|
||||
bounced: boolean;
|
||||
clicked: boolean;
|
||||
complained: boolean;
|
||||
deliveryDelayed: boolean;
|
||||
errorMessage: string | null;
|
||||
failed: boolean;
|
||||
opened: boolean;
|
||||
status:
|
||||
| "waiting"
|
||||
| "queued"
|
||||
| "cancelled"
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "delivery_delayed"
|
||||
| "bounced"
|
||||
| "failed";
|
||||
} | null
|
||||
>;
|
||||
handleEmailEvent: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ event: any },
|
||||
null
|
||||
>;
|
||||
sendEmail: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{
|
||||
bcc?: Array<string>;
|
||||
cc?: Array<string>;
|
||||
from: string;
|
||||
headers?: Array<{ name: string; value: string }>;
|
||||
html?: string;
|
||||
options: {
|
||||
apiKey: string;
|
||||
initialBackoffMs: number;
|
||||
onEmailEvent?: { fnHandle: string };
|
||||
retryAttempts: number;
|
||||
testMode: boolean;
|
||||
};
|
||||
replyTo?: Array<string>;
|
||||
subject?: string;
|
||||
template?: {
|
||||
id: string;
|
||||
variables?: Record<string, string | number>;
|
||||
};
|
||||
text?: string;
|
||||
to: Array<string>;
|
||||
},
|
||||
string
|
||||
>;
|
||||
updateManualEmail: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{
|
||||
emailId: string;
|
||||
errorMessage?: string;
|
||||
resendId?: string;
|
||||
status:
|
||||
| "waiting"
|
||||
| "queued"
|
||||
| "cancelled"
|
||||
| "sent"
|
||||
| "delivered"
|
||||
| "delivery_delayed"
|
||||
| "bounced"
|
||||
| "failed";
|
||||
},
|
||||
null
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
23
convex/_generated/api.js
Normal file
23
convex/_generated/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi, componentsGeneric } from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export const api = anyApi;
|
||||
export const internal = anyApi;
|
||||
export const components = componentsGeneric();
|
||||
60
convex/_generated/dataModel.d.ts
vendored
Normal file
60
convex/_generated/dataModel.d.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated data model types.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
DataModelFromSchemaDefinition,
|
||||
DocumentByName,
|
||||
TableNamesInDataModel,
|
||||
SystemTableNames,
|
||||
} from "convex/server";
|
||||
import type { GenericId } from "convex/values";
|
||||
import schema from "../schema.js";
|
||||
|
||||
/**
|
||||
* The names of all of your Convex tables.
|
||||
*/
|
||||
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||
|
||||
/**
|
||||
* The type of a document stored in Convex.
|
||||
*
|
||||
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||
*/
|
||||
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||
DataModel,
|
||||
TableName
|
||||
>;
|
||||
|
||||
/**
|
||||
* An identifier for a document in Convex.
|
||||
*
|
||||
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||
*
|
||||
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
|
||||
*
|
||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||
* strings when type checking.
|
||||
*
|
||||
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||
*/
|
||||
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||
GenericId<TableName>;
|
||||
|
||||
/**
|
||||
* A type describing your Convex data model.
|
||||
*
|
||||
* This type includes information about what tables you have, the type of
|
||||
* documents stored in those tables, and the indexes defined on them.
|
||||
*
|
||||
* This type is used to parameterize methods like `queryGeneric` and
|
||||
* `mutationGeneric` to make them type-safe.
|
||||
*/
|
||||
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||
143
convex/_generated/server.d.ts
vendored
Normal file
143
convex/_generated/server.d.ts
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionBuilder,
|
||||
HttpActionBuilder,
|
||||
MutationBuilder,
|
||||
QueryBuilder,
|
||||
GenericActionCtx,
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const query: QueryBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
*
|
||||
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||
* code and code with side-effects, like calling third-party services.
|
||||
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||
*
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const action: ActionBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
*
|
||||
* The wrapped function will be used to respond to HTTP requests received
|
||||
* by a Convex deployment if the requests matches the path and method where
|
||||
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||
* and a Fetch API `Request` object as its second.
|
||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||
*/
|
||||
export declare const httpAction: HttpActionBuilder;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex query functions.
|
||||
*
|
||||
* The query context is passed as the first argument to any Convex query
|
||||
* function run on the server.
|
||||
*
|
||||
* This differs from the {@link MutationCtx} because all of the services are
|
||||
* read-only.
|
||||
*/
|
||||
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex mutation functions.
|
||||
*
|
||||
* The mutation context is passed as the first argument to any Convex mutation
|
||||
* function run on the server.
|
||||
*/
|
||||
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex action functions.
|
||||
*
|
||||
* The action context is passed as the first argument to any Convex action
|
||||
* function run on the server.
|
||||
*/
|
||||
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* An interface to read from the database within Convex query functions.
|
||||
*
|
||||
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||
* building a query.
|
||||
*/
|
||||
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||
|
||||
/**
|
||||
* An interface to read from and write to the database within Convex mutation
|
||||
* functions.
|
||||
*
|
||||
* Convex guarantees that all writes within a single mutation are
|
||||
* executed atomically, so you never have to worry about partial writes leaving
|
||||
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||
* for the guarantees Convex provides your functions.
|
||||
*/
|
||||
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||
93
convex/_generated/server.js
Normal file
93
convex/_generated/server.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
actionGeneric,
|
||||
httpActionGeneric,
|
||||
queryGeneric,
|
||||
mutationGeneric,
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const query = queryGeneric;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalQuery = internalQueryGeneric;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const mutation = mutationGeneric;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalMutation = internalMutationGeneric;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
*
|
||||
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||
* code and code with side-effects, like calling third-party services.
|
||||
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||
*
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const action = actionGeneric;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalAction = internalActionGeneric;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
*
|
||||
* The wrapped function will be used to respond to HTTP requests received
|
||||
* by a Convex deployment if the requests matches the path and method where
|
||||
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||
* and a Fetch API `Request` object as its second.
|
||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||
*/
|
||||
export const httpAction = httpActionGeneric;
|
||||
@@ -26,6 +26,8 @@ async function setupUserAndVariant(t: ReturnType<typeof convexTest>) {
|
||||
status: "active",
|
||||
categoryId,
|
||||
tags: [],
|
||||
parentCategorySlug: "toys",
|
||||
childCategorySlug: "toys",
|
||||
});
|
||||
variantId = await ctx.db.insert("productVariants", {
|
||||
productId,
|
||||
@@ -35,6 +37,8 @@ async function setupUserAndVariant(t: ReturnType<typeof convexTest>) {
|
||||
stockQuantity: 50,
|
||||
attributes: { color: "Red" },
|
||||
isActive: true,
|
||||
weight: 100,
|
||||
weightUnit: "g",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user