Skip to content

Commit 739c9b3

Browse files
feat(merch): add Printful and Stripe checkout integration
Wire shop checkout to a Node API (Stripe sessions, webhooks, Printful orders), extend chart rendering for print themes and mugs, and document infrastructure prerequisites for future production on 6axiscompass.uk. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2449c67 commit 739c9b3

55 files changed

Lines changed: 3556 additions & 207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,24 @@ API_SECRET=your-secret-here
77
# Phase 2 private write (not implemented yet)
88
# ADMIN_SECRET=your-admin-secret-here
99
# API_CHART_RATE_LIMIT=60
10+
11+
# Merch / Printful / Stripe
12+
PRINTFUL_API_KEY=
13+
STRIPE_SECRET_KEY=
14+
STRIPE_WEBHOOK_SECRET=
15+
MERCH_SUCCESS_URL=https://6axiscompass.uk/shop.html?paid=1
16+
MERCH_CANCEL_URL=https://6axiscompass.uk/shop.html
17+
MERCH_API_BASE=https://api.6axiscompass.uk
18+
MERCH_ARTWORK_PUBLIC_ORIGIN=https://api.6axiscompass.uk
19+
MERCH_ARTWORK_STORAGE=local
20+
MERCH_ARTWORK_LOCAL_DIR=
21+
MERCH_ARTWORK_BASE_URL=
22+
# S3-compatible storage (when MERCH_ARTWORK_STORAGE=s3)
23+
# MERCH_S3_BUCKET=
24+
# MERCH_S3_REGION=auto
25+
# MERCH_S3_ENDPOINT=
26+
# MERCH_S3_ACCESS_KEY=
27+
# MERCH_S3_SECRET_KEY=
28+
29+
# Static shop build (npm run build)
30+
# MERCH_API_BASE=https://your-api-host

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: CI
22
on:
33
push:
4-
branches: [main]
4+
branches: [main, feat/merch]
55
pull_request:
66
repository_dispatch:
77
types: [paper-revised]
@@ -33,6 +33,7 @@ jobs:
3333
# Allow PWA manifest and apple-touch-icon links (local files only)
3434
! grep -qE '<link[^>]*href="https?://' dist/index.html
3535
- run: npm install
36+
- run: npm test
3637
- run: npm run generate-artifacts
3738
- name: Verify paper artifacts
3839
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ node_modules/
33
*.log
44
.env
55
.env.local
6+
api/data/
67
# Google Drive mount (symlink → Drive "6-Axis Compass/") — not git
78
/docs-gd-6-axis-compass
89
docs-gd-6-axis-compass/

API.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ Production chart API hosting is maintainer-operated (not served from GitHub Page
3737

3838
| Zone | Auth | Routes |
3939
|------|------|--------|
40-
| **Public read** | None (default) | `GET /api/health`, `GET /api/actors`, `GET /api/actors/:slug`, `POST /api/chart`, `GET /api/axes`, `GET /api/openapi.json` |
40+
| **Public read** | None (default) | `GET /api/health`, `GET /api/actors`, `GET /api/actors/:slug`, `POST /api/chart`, `GET /api/axes`, `GET /api/openapi.json`, `GET /api/merch/prices`, `POST /api/checkout/session` |
41+
| **Webhooks** | Stripe signature | `POST /api/webhooks/stripe` |
4142
| **Private write** | `ADMIN_SECRET` (Phase 2) | `POST /api/admin/*` — not implemented yet |
4243

4344
Specification: [`docs.repo-sync/feature-request-public-private-api-v0.1.0.md`](docs.repo-sync/feature-request-public-private-api-v0.1.0.md).
@@ -53,9 +54,28 @@ Specification: [`docs.repo-sync/feature-request-public-private-api-v0.1.0.md`](d
5354
| `API_SECRET` | *(unset)* | Legacy Bearer for read when `API_PUBLIC_READ=false`; optional when public read is on |
5455
| `ADMIN_SECRET` | *(unset)* | Future private write API (Phase 2) |
5556
| `API_CHART_RATE_LIMIT` | `60` | Max `POST /api/chart` requests per client IP per minute |
57+
| `STRIPE_SECRET_KEY` | *(unset)* | Stripe Checkout (merch) |
58+
| `STRIPE_WEBHOOK_SECRET` | *(unset)* | Stripe webhook verification |
59+
| `PRINTFUL_API_KEY` | *(unset)* | Printful order submission |
60+
| `MERCH_SUCCESS_URL` | *(unset)* | Stripe success redirect |
61+
| `MERCH_CANCEL_URL` | *(unset)* | Stripe cancel redirect |
62+
| `MERCH_ARTWORK_PUBLIC_ORIGIN` | *(unset)* | Public origin for artwork URLs Printful fetches |
5663

5764
Copy `.env.example` to `.env.local` for local development.
5865

66+
### Merch endpoints
67+
68+
See [`docs.repo-sync/merch-printful-integration.md`](docs.repo-sync/merch-printful-integration.md).
69+
70+
**`POST /api/checkout/session`** — body: compass scores, product (`garment` or `productType: mug`), size, colours. Returns `{ url, orderId }` Stripe Checkout URL.
71+
72+
**`GET /api/merch/prices`** — returns GBP prices from `api/config/printful-catalog.json`.
73+
74+
**`GET /api/orders/:id`** — public order status after payment.
75+
76+
**`POST /api/chart`** — additional fields: `background` (`white`|`transparent`|`dark`), `labelMerch` (boolean).
77+
78+
5979
### Secret naming (integrations)
6080

6181
| Name | Use | Required for public chart fetch? |

Dockerfile.api

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM node:20-slim
2+
3+
RUN apt-get update && apt-get install -y --no-install-recommends \
4+
libvips-dev \
5+
&& rm -rf /var/lib/apt/lists/*
6+
7+
WORKDIR /app
8+
9+
COPY package.json package-lock.json* ./
10+
RUN npm install --omit=dev
11+
12+
COPY api/ ./api/
13+
COPY src/ ./src/
14+
COPY data/ ./data/
15+
16+
ENV API_PORT=3000
17+
ENV MERCH_ARTWORK_LOCAL_DIR=/app/api/data/artwork
18+
19+
EXPOSE 3000
20+
CMD ["node", "api/server.js"]

README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,22 +145,26 @@ https://earlution.github.io/6-axis-compass/#v2;c=5.0,e=5.0,m=5.0,s=5.0,l=5.0,a=5
145145

146146
Visiting a valid hash URL skips the quiz and renders the results screen immediately. The URL auto-updates when orientation or axis order changes, so copying the address bar always captures the current view. `v1` URLs are backwards-compatible.
147147

148-
### Merch shop prototype (`merch-shop` branch)
148+
### Merch shop (`feat/merch`)
149149

150-
Custom apparel flow (Printful-ready UI, **checkout stubbed**): results → garment mockup carousel → [`shop.html`](./src/shop.html) configurator → stub checkout modal.
150+
Custom prints (apparel + mugs): results → [`shop.html`](./src/shop.html) → Stripe Checkout → Printful fulfilment (UK/EU).
151151

152152
| Output | Description |
153153
|--------|-------------|
154-
| `dist/shop.html` | Shop configurator (same styles as main app) |
155-
| `dist/assets/merch/` | Placeholder garment mockup SVGs |
154+
| `dist/shop.html` | Shop configurator |
155+
| `dist/assets/merch/` | Garment and mug mockup SVGs |
156+
| `dist/merch-terms.html` | Shop terms |
157+
| `dist/merch-privacy.html` | Shop privacy notice |
156158

157-
**Demo locally:** `npm run build` then serve `dist/` (see [`docs/merch-prototype.md`](./docs/merch-prototype.md)).
159+
**Demo locally:** `npm install && npm run build` then serve `dist/`; run `node api/server.js` with Stripe test keys (see [`docs.repo-sync/merch-prototype.md`](./docs.repo-sync/merch-prototype.md)).
158160

159-
- **v3 hash** on shop URLs carries scores, comparisons (actor slugs), garment, colour, and chart ink theme.
160-
- **sessionStorage** draft preserves custom actors and uploaded maps.
161-
- Future Printful/Stripe wiring: [`docs/merch-printful-integration.md`](./docs/merch-printful-integration.md).
161+
- **v3 hash** scores, comparisons, product, mug size (`;ms=`), map colour
162+
- **Checkout API** `POST /api/checkout/session` (deploy separately from Pages)
163+
- **Docs**[`docs.repo-sync/merch-printful-integration.md`](./docs.repo-sync/merch-printful-integration.md), [`merch-infrastructure.md`](./docs.repo-sync/merch-infrastructure.md), [`merch-deploy.md`](./docs.repo-sync/merch-deploy.md)
162164

163-
Tagged releases for this prototype use `merch-prototype-v*` (see git tags).
165+
Build with API URL: `MERCH_API_BASE=https://api.6axiscompass.uk npm run build`
166+
167+
Production domain: **6axiscompass.uk** (123-reg). Upload `dist/` to web hosting; API on `api.6axiscompass.uk`. See [`docs.repo-sync/hosting-123-reg.md`](./docs.repo-sync/hosting-123-reg.md).
164168

165169
## Paper Artifacts
166170

api/config/printful-catalog.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"_meta": {
3+
"note": "Replace variant_id values with IDs from your Printful dashboard before going live.",
4+
"fulfilment": "UK/EU",
5+
"ethicalBaseline": "Printful audited partner facilities; EU/UK routing preferred",
6+
"lastReviewed": "2026-05-22",
7+
"productsReviewed": {
8+
"tee": "Bella+Canvas 3001 (DTG) — verify in Printful EU catalogue",
9+
"sweatshirt": "Gildan 18000 (DTG) — verify in Printful EU catalogue",
10+
"hoodie": "Gildan 18500 (DTG) — verify in Printful EU catalogue",
11+
"mug": "White Glossy Mug 11oz/15oz (sublimation) — verify in Printful EU catalogue"
12+
}
13+
},
14+
"products": {
15+
"tee": {
16+
"label": "T-shirt",
17+
"productType": "apparel",
18+
"printArea": { "width": 4500, "height": 5400 },
19+
"priceGBP": 24.99,
20+
"variants": {
21+
"white": {
22+
"XS": 4012, "S": 4013, "M": 4014, "L": 4015, "XL": 4016, "XXL": 4017
23+
},
24+
"black": {
25+
"XS": 4018, "S": 4019, "M": 4020, "L": 4021, "XL": 4022, "XXL": 4023
26+
}
27+
}
28+
},
29+
"sweatshirt": {
30+
"label": "Sweatshirt",
31+
"productType": "apparel",
32+
"printArea": { "width": 4500, "height": 5400 },
33+
"priceGBP": 34.99,
34+
"variants": {
35+
"white": {
36+
"XS": 4024, "S": 4025, "M": 4026, "L": 4027, "XL": 4028, "XXL": 4029
37+
},
38+
"black": {
39+
"XS": 4030, "S": 4031, "M": 4032, "L": 4033, "XL": 4034, "XXL": 4035
40+
}
41+
}
42+
},
43+
"hoodie": {
44+
"label": "Hoodie",
45+
"productType": "apparel",
46+
"printArea": { "width": 4500, "height": 5400 },
47+
"priceGBP": 39.99,
48+
"variants": {
49+
"white": {
50+
"XS": 4036, "S": 4037, "M": 4038, "L": 4039, "XL": 4040, "XXL": 4041
51+
},
52+
"black": {
53+
"XS": 4042, "S": 4043, "M": 4044, "L": 4045, "XL": 4046, "XXL": 4047
54+
}
55+
}
56+
},
57+
"mug": {
58+
"label": "Mug",
59+
"productType": "mug",
60+
"printArea": { "width": 2700, "height": 1125 },
61+
"layout": "mug",
62+
"priceGBP": 14.99,
63+
"variants": {
64+
"white": {
65+
"11oz": 9327,
66+
"15oz": 9328
67+
}
68+
}
69+
}
70+
}
71+
}

api/lib/artwork-storage.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { writeFileSync, mkdirSync, existsSync } from 'fs';
2+
import { join, dirname } from 'path';
3+
import { fileURLToPath } from 'url';
4+
import { randomUUID } from 'crypto';
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url));
7+
const LOCAL_DIR = process.env.MERCH_ARTWORK_LOCAL_DIR ||
8+
join(__dirname, '..', 'data', 'artwork');
9+
10+
function getBaseUrl() {
11+
return (process.env.MERCH_ARTWORK_BASE_URL || '').replace(/\/$/, '');
12+
}
13+
14+
function ensureLocalDir() {
15+
if (!existsSync(LOCAL_DIR)) mkdirSync(LOCAL_DIR, { recursive: true });
16+
}
17+
18+
async function uploadLocal(buffer, ext = 'png') {
19+
ensureLocalDir();
20+
const id = randomUUID();
21+
const filename = `${id}.${ext}`;
22+
const filepath = join(LOCAL_DIR, filename);
23+
writeFileSync(filepath, buffer);
24+
const base = getBaseUrl();
25+
if (!base) {
26+
return { id, filename, url: `/api/artwork/${filename}` };
27+
}
28+
return { id, filename, url: `${base}/${filename}` };
29+
}
30+
31+
async function uploadS3(buffer, ext = 'png') {
32+
const bucket = process.env.MERCH_S3_BUCKET;
33+
const region = process.env.MERCH_S3_REGION || 'auto';
34+
const endpoint = process.env.MERCH_S3_ENDPOINT;
35+
const accessKey = process.env.MERCH_S3_ACCESS_KEY;
36+
const secretKey = process.env.MERCH_S3_SECRET_KEY;
37+
const publicBase = getBaseUrl();
38+
39+
if (!bucket || !accessKey || !secretKey) {
40+
throw new Error('S3 storage requires MERCH_S3_BUCKET, MERCH_S3_ACCESS_KEY, MERCH_S3_SECRET_KEY');
41+
}
42+
43+
const id = randomUUID();
44+
const key = `artwork/${id}.${ext}`;
45+
const host = endpoint || `https://${bucket}.s3.${region}.amazonaws.com`;
46+
const url = `${host.replace(/\/$/, '')}/${key}`;
47+
48+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
49+
const client = new S3Client({
50+
region,
51+
endpoint: endpoint || undefined,
52+
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
53+
forcePathStyle: !!endpoint
54+
});
55+
56+
await client.send(new PutObjectCommand({
57+
Bucket: bucket,
58+
Key: key,
59+
Body: buffer,
60+
ContentType: `image/${ext}`,
61+
ACL: 'public-read'
62+
}));
63+
64+
const publicUrl = publicBase ? `${publicBase}/${key}` : url;
65+
return { id, filename: key, url: publicUrl };
66+
}
67+
68+
export async function uploadArtwork(buffer, ext = 'png') {
69+
const mode = (process.env.MERCH_ARTWORK_STORAGE || 'local').toLowerCase();
70+
if (mode === 's3') return uploadS3(buffer, ext);
71+
return uploadLocal(buffer, ext);
72+
}
73+
74+
export function getLocalArtworkPath(filename) {
75+
if (!filename || filename.includes('..') || filename.includes('/')) return null;
76+
const filepath = join(LOCAL_DIR, filename);
77+
if (!existsSync(filepath)) return null;
78+
return filepath;
79+
}

0 commit comments

Comments
 (0)