From 9be4986dd3c974f7de761321dc6bd39946474c7b Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 13 Aug 2026 21:07:54 +0000 Subject: [PATCH] Initial commit: redefined-designs storefront --- .gitignore | 7 + Dockerfile | 23 ++ backend/init.sql | 59 +++++ backend/package.json | 28 +++ backend/src/db.ts | 9 + backend/src/mailer.ts | 24 ++ backend/src/middleware/customerAuth.ts | 29 +++ backend/src/routes/admin.ts | 124 ++++++++++ backend/src/routes/adminCustomers.ts | 40 ++++ backend/src/routes/customers.ts | 186 +++++++++++++++ backend/src/routes/demo.ts | 39 ++++ backend/src/routes/items.ts | 28 +++ backend/src/routes/paypal.ts | 156 +++++++++++++ backend/src/routes/public.ts | 21 ++ backend/src/server.ts | 59 +++++ backend/src/types.ts | 20 ++ backend/tsconfig.json | 14 ++ frontend/index.html | 12 + frontend/package.json | 25 ++ frontend/src/App.tsx | 68 ++++++ frontend/src/admin/Admin.tsx | 221 ++++++++++++++++++ frontend/src/admin/Customers.tsx | 150 ++++++++++++ frontend/src/admin/adminCustomersApi.ts | 44 ++++ frontend/src/api.ts | 83 +++++++ frontend/src/components/ItemCard.tsx | 126 ++++++++++ frontend/src/components/MarkdownView.tsx | 15 ++ frontend/src/customer/Account.tsx | 90 +++++++ frontend/src/customer/CustomerAuthContext.tsx | 32 +++ frontend/src/customer/Login.tsx | 51 ++++ frontend/src/customer/PrivacyPolicy.tsx | 50 ++++ frontend/src/customer/Register.tsx | 63 +++++ frontend/src/customer/customerApi.ts | 69 ++++++ frontend/src/main.tsx | 47 ++++ frontend/src/paypal.ts | 14 ++ frontend/src/styles.css | 33 +++ frontend/src/theme/ThemeContext.tsx | 40 ++++ frontend/tsconfig.json | 15 ++ frontend/vite.config.ts | 7 + 38 files changed, 2121 insertions(+) create mode 100755 .gitignore create mode 100755 Dockerfile create mode 100755 backend/init.sql create mode 100755 backend/package.json create mode 100755 backend/src/db.ts create mode 100755 backend/src/mailer.ts create mode 100755 backend/src/middleware/customerAuth.ts create mode 100755 backend/src/routes/admin.ts create mode 100755 backend/src/routes/adminCustomers.ts create mode 100755 backend/src/routes/customers.ts create mode 100755 backend/src/routes/demo.ts create mode 100755 backend/src/routes/items.ts create mode 100755 backend/src/routes/paypal.ts create mode 100755 backend/src/routes/public.ts create mode 100755 backend/src/server.ts create mode 100755 backend/src/types.ts create mode 100755 backend/tsconfig.json create mode 100755 frontend/index.html create mode 100755 frontend/package.json create mode 100755 frontend/src/App.tsx create mode 100755 frontend/src/admin/Admin.tsx create mode 100755 frontend/src/admin/Customers.tsx create mode 100755 frontend/src/admin/adminCustomersApi.ts create mode 100755 frontend/src/api.ts create mode 100755 frontend/src/components/ItemCard.tsx create mode 100755 frontend/src/components/MarkdownView.tsx create mode 100755 frontend/src/customer/Account.tsx create mode 100755 frontend/src/customer/CustomerAuthContext.tsx create mode 100755 frontend/src/customer/Login.tsx create mode 100755 frontend/src/customer/PrivacyPolicy.tsx create mode 100755 frontend/src/customer/Register.tsx create mode 100755 frontend/src/customer/customerApi.ts create mode 100755 frontend/src/main.tsx create mode 100755 frontend/src/paypal.ts create mode 100755 frontend/src/styles.css create mode 100755 frontend/src/theme/ThemeContext.tsx create mode 100755 frontend/tsconfig.json create mode 100755 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..39de20f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +frontend/dist/ +backend/dist/ +*.log +.DS_Store +uploads/ diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..33f46f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-bookworm-slim AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +FROM node:20-bookworm-slim AS backend-build +WORKDIR /app/backend +COPY backend/package.json ./ +RUN npm install +COPY backend/ ./ +RUN npm run build + +FROM node:20-bookworm-slim +WORKDIR /app +COPY --from=backend-build /app/backend/package.json ./ +RUN npm install --omit=dev +COPY --from=backend-build /app/backend/dist ./dist +COPY --from=frontend-build /app/frontend/dist ./public +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["node", "dist/server.js"] diff --git a/backend/init.sql b/backend/init.sql new file mode 100755 index 0000000..d2c4fcc --- /dev/null +++ b/backend/init.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + price_cents INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'available', + reserved_until TIMESTAMPTZ, + sold_at TIMESTAMPTZ, + paypal_order_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS item_images ( + id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + image_path TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS customers ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT, + email_verified BOOLEAN NOT NULL DEFAULT false, + marketing_consent BOOLEAN NOT NULL DEFAULT false, + marketing_consent_at TIMESTAMPTZ, + marketing_consent_text TEXT, + unsubscribe_token TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS customer_sessions ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS customer_tokens ( + token TEXT PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS orders ( + id SERIAL PRIMARY KEY, + item_id INTEGER REFERENCES items(id), + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + processor TEXT NOT NULL, + processor_order_id TEXT, + amount_cents INTEGER, + status TEXT, + raw_event JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/package.json b/backend/package.json new file mode 100755 index 0000000..81469b5 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "redefined-designs-backend", + "version": "1.0.0", + "private": true, + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js" + }, + "dependencies": { + "express": "^4.19.2", + "pg": "^8.12.0", + "multer": "^1.4.5-lts.1", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "nodemailer": "^6.9.14" + }, + "devDependencies": { + "typescript": "^5.5.4", + "@types/express": "^4.17.21", + "@types/node": "^20.14.15", + "@types/multer": "^1.4.11", + "@types/pg": "^8.11.6", + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.7", + "@types/nodemailer": "^6.4.15" + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100755 index 0000000..54461c5 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,9 @@ +import { Pool } from 'pg'; + +export const pool = new Pool({ + host: process.env.PGHOST, + port: parseInt(process.env.PGPORT || '5432', 10), + user: process.env.PGUSER, + password: process.env.PGPASSWORD, + database: process.env.PGDATABASE +}); diff --git a/backend/src/mailer.ts b/backend/src/mailer.ts new file mode 100755 index 0000000..12d10d7 --- /dev/null +++ b/backend/src/mailer.ts @@ -0,0 +1,24 @@ +import nodemailer from 'nodemailer'; + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.gmail.com', + port: parseInt(process.env.SMTP_PORT || '465', 10), + secure: process.env.SMTP_SECURE !== 'false', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD + } +}); + +export async function sendMail(to: string, subject: string, html: string): Promise { + if (!process.env.SMTP_USER || !process.env.SMTP_PASSWORD) { + console.warn(`SMTP not configured — skipping email to ${to}: "${subject}"`); + return; + } + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to, + subject, + html + }); +} diff --git a/backend/src/middleware/customerAuth.ts b/backend/src/middleware/customerAuth.ts new file mode 100755 index 0000000..bf0c5b5 --- /dev/null +++ b/backend/src/middleware/customerAuth.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from 'express'; +import { pool } from '../db'; + +declare global { + namespace Express { + interface Request { + customerId?: number; + } + } +} + +export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise { + const token = req.cookies?.rd_session; + if (!token) return next(); + const { rows } = await pool.query( + `SELECT customer_id FROM customer_sessions WHERE token = $1 AND expires_at > now()`, + [token] + ); + if (rows.length) req.customerId = rows[0].customer_id; + next(); +} + +export function requireCustomer(req: Request, res: Response, next: NextFunction): void { + if (!req.customerId) { + res.status(401).json({ error: 'not authenticated' }); + return; + } + next(); +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100755 index 0000000..965cedd --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,124 @@ +import { Router, Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; +import { pool } from '../db'; + +const router = Router(); + +const storage = multer.diskStorage({ + destination: '/app/uploads', + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}${ext}`); + } +}); +const upload = multer({ storage }); + +const SELECT_WITH_IMAGES = ` + SELECT i.*, + COALESCE( + json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) + ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), + '[]' + ) AS images + FROM items i + LEFT JOIN item_images img ON img.item_id = i.id +`; + +router.get('/items', async (_req: Request, res: Response) => { + const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`); + res.json(rows); +}); + +router.post('/items', upload.array('images', 6), async (req: Request, res: Response) => { + const { name, description, price } = req.body; + const files = (req.files as Express.Multer.File[]) || []; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query( + `INSERT INTO items (name, description, price_cents) VALUES ($1, $2, $3) RETURNING *`, + [name, description, Math.round(parseFloat(price) * 100)] + ); + const item = rows[0]; + for (let i = 0; i < files.length; i++) { + await client.query( + `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`, + [item.id, `/uploads/${files[i].filename}`, i] + ); + } + await client.query('COMMIT'); + const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [item.id]); + res.json(full[0]); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.put('/items/:id', upload.array('images', 6), async (req: Request, res: Response) => { + const { name, description, price } = req.body; + const files = (req.files as Express.Multer.File[]) || []; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + `UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`, + [name, description, Math.round(parseFloat(price) * 100), req.params.id] + ); + if (files.length) { + const { rows: existing } = await client.query( + `SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`, + [req.params.id] + ); + let nextSort = existing[0].max_sort + 1; + for (const file of files) { + await client.query( + `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`, + [req.params.id, `/uploads/${file.filename}`, nextSort++] + ); + } + } + await client.query('COMMIT'); + const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]); + res.json(full[0]); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.delete('/items/:id', async (req: Request, res: Response) => { + await pool.query(`DELETE FROM items WHERE id = $1`, [req.params.id]); + res.status(204).end(); +}); + +router.delete('/items/:id/images/:imageId', async (req: Request, res: Response) => { + await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]); + res.status(204).end(); +}); + +router.post('/items/:id/mark-sold', async (req: Request, res: Response) => { + const { rows } = await pool.query( + `UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`, + [req.params.id] + ); + res.json(rows[0]); +}); + +router.post('/items/:id/mark-available', async (req: Request, res: Response) => { + const { rows } = await pool.query( + `UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL + WHERE id=$1 RETURNING *`, + [req.params.id] + ); + res.json(rows[0]); +}); + +export default router; diff --git a/backend/src/routes/adminCustomers.ts b/backend/src/routes/adminCustomers.ts new file mode 100755 index 0000000..dfecf28 --- /dev/null +++ b/backend/src/routes/adminCustomers.ts @@ -0,0 +1,40 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query(` + SELECT + c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at, + COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count, + COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents, + MAX(o.created_at) AS last_order_at + FROM customers c + LEFT JOIN orders o ON o.customer_id = c.id + GROUP BY c.id + ORDER BY c.created_at DESC + `); + res.json(rows); +}); + +router.get('/:id', async (req: Request, res: Response) => { + const { rows: customerRows } = await pool.query( + `SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at + FROM customers WHERE id = $1`, + [req.params.id] + ); + if (!customerRows.length) return res.status(404).json({ error: 'not found' }); + + const { rows: orderRows } = await pool.query( + `SELECT o.id, o.processor, o.processor_order_id, o.amount_cents, o.status, o.created_at, i.name AS item_name + FROM orders o JOIN items i ON i.id = o.item_id + WHERE o.customer_id = $1 + ORDER BY o.created_at DESC`, + [req.params.id] + ); + + res.json({ customer: customerRows[0], orders: orderRows }); +}); + +export default router; diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts new file mode 100755 index 0000000..2901a44 --- /dev/null +++ b/backend/src/routes/customers.ts @@ -0,0 +1,186 @@ +import { Router, Request, Response } from 'express'; +import bcrypt from 'bcryptjs'; +import crypto from 'node:crypto'; +import { pool } from '../db'; +import { requireCustomer } from '../middleware/customerAuth'; +import { sendMail } from '../mailer'; + +const router = Router(); + +const SESSION_DAYS = 30; +const MARKETING_CONSENT_TEXT = + 'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.'; + +function setSessionCookie(res: Response, token: string) { + res.cookie('rd_session', token, { + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000 + }); +} + +async function createSession(customerId: number): Promise { + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000); + await pool.query( + `INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`, + [token, customerId, expiresAt] + ); + return token; +} + +function publicCustomer(c: any) { + return { + id: c.id, + email: c.email, + name: c.name, + email_verified: c.email_verified, + marketing_consent: c.marketing_consent, + created_at: c.created_at + }; +} + +router.post('/register', async (req: Request, res: Response) => { + const { email, password, name, marketingConsent } = req.body; + if (!email || !password || String(password).length < 8) { + return res.status(400).json({ error: 'valid email and password (min 8 chars) required' }); + } + const normalizedEmail = String(email).toLowerCase().trim(); + const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]); + if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' }); + + const passwordHash = await bcrypt.hash(password, 12); + const unsubscribeToken = crypto.randomBytes(16).toString('hex'); + const consent = !!marketingConsent; + + const { rows } = await pool.query( + `INSERT INTO customers (email, password_hash, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, + [ + normalizedEmail, passwordHash, name || null, + consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null, + unsubscribeToken + ] + ); + const customer = rows[0]; + + const verifyToken = crypto.randomBytes(24).toString('hex'); + await pool.query( + `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`, + [verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)] + ); + const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`; + sendMail( + customer.email, + 'Verify your Redefined Designs account', + `

Welcome! Please verify your email to finish setting up your account.

` + ).catch(err => console.error('verify email send failed', err)); + + const sessionToken = await createSession(customer.id); + setSessionCookie(res, sessionToken); + res.json(publicCustomer(customer)); +}); + +router.post('/verify-email', async (req: Request, res: Response) => { + const { token } = req.body; + const { rows } = await pool.query( + `SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`, + [token] + ); + if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' }); + await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]); + await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]); + res.json({ status: 'verified' }); +}); + +router.post('/login', async (req: Request, res: Response) => { + const { email, password } = req.body; + const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]); + const customer = rows[0]; + if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) { + return res.status(401).json({ error: 'invalid email or password' }); + } + const sessionToken = await createSession(customer.id); + setSessionCookie(res, sessionToken); + res.json(publicCustomer(customer)); +}); + +router.post('/logout', async (req: Request, res: Response) => { + const token = req.cookies?.rd_session; + if (token) await pool.query(`DELETE FROM customer_sessions WHERE token = $1`, [token]); + res.clearCookie('rd_session'); + res.status(204).end(); +}); + +router.get('/me', requireCustomer, async (req: Request, res: Response) => { + const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + res.json(publicCustomer(rows[0])); +}); + +router.put('/me', requireCustomer, async (req: Request, res: Response) => { + const { name } = req.body; + const { rows } = await pool.query( + `UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`, + [name || null, req.customerId] + ); + res.json(publicCustomer(rows[0])); +}); + +router.post('/change-password', requireCustomer, async (req: Request, res: Response) => { + const { currentPassword, newPassword } = req.body; + if (!newPassword || String(newPassword).length < 8) { + return res.status(400).json({ error: 'new password must be at least 8 characters' }); + } + const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); + const customer = rows[0]; + if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) { + return res.status(401).json({ error: 'current password is incorrect' }); + } + const newHash = await bcrypt.hash(newPassword, 12); + await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]); + res.status(204).end(); +}); + +// Explicit, revocable marketing consent (GDPR Art. 7 / ePrivacy) +router.post('/me/consent', requireCustomer, async (req: Request, res: Response) => { + const consent = !!req.body.marketingConsent; + await pool.query( + `UPDATE customers SET marketing_consent = $1, marketing_consent_at = now(), marketing_consent_text = $2 WHERE id = $3`, + [consent, consent ? MARKETING_CONSENT_TEXT : 'Withdrew consent via account settings', req.customerId] + ); + res.status(204).end(); +}); + +router.get('/me/orders', requireCustomer, async (req: Request, res: Response) => { + const { rows } = await pool.query( + `SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name + FROM orders o JOIN items i ON i.id = o.item_id + WHERE o.customer_id = $1 ORDER BY o.created_at DESC`, + [req.customerId] + ); + res.json(rows); +}); + +// GDPR right to access / data portability +router.get('/me/export', requireCustomer, async (req: Request, res: Response) => { + const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); + const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]); + res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"'); + res.json({ + customer: publicCustomer(customerRows[0]), + orders: orderRows, + exported_at: new Date().toISOString() + }); +}); + +// GDPR right to erasure — order records kept for accounting but stripped of the customer link +router.delete('/me', requireCustomer, async (req: Request, res: Response) => { + await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]); + await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]); + res.clearCookie('rd_session'); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/demo.ts b/backend/src/routes/demo.ts new file mode 100755 index 0000000..c4b93ea --- /dev/null +++ b/backend/src/routes/demo.ts @@ -0,0 +1,39 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +router.post('/:id/purchase', async (req: Request, res: Response) => { + if (process.env.DEMO_MODE === 'false') { + return res.status(403).json({ error: 'demo mode disabled' }); + } + const itemId = req.params.id; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); + const item = rows[0]; + if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } + if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); } + + const { rows: updated } = await client.query( + `UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 RETURNING *`, + [itemId] + ); + await client.query( + `INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event) + VALUES ($1, $2, 'demo', $3, $4, 'completed', $5)`, + [itemId, req.customerId || null, `demo-${Date.now()}`, item.price_cents, JSON.stringify({ demo: true })] + ); + await client.query('COMMIT'); + res.json({ status: 'sold', item: updated[0] }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +export default router; diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts new file mode 100755 index 0000000..6f6a575 --- /dev/null +++ b/backend/src/routes/items.ts @@ -0,0 +1,28 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +const SELECT_WITH_IMAGES = ` + SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, + COALESCE( + json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) + ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), + '[]' + ) AS images + FROM items i + LEFT JOIN item_images img ON img.item_id = i.id +`; + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`); + res.json(rows); +}); + +router.get('/:id', async (req: Request, res: Response) => { + const { rows } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + res.json(rows[0]); +}); + +export default router; diff --git a/backend/src/routes/paypal.ts b/backend/src/routes/paypal.ts new file mode 100755 index 0000000..d9c7852 --- /dev/null +++ b/backend/src/routes/paypal.ts @@ -0,0 +1,156 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); +const webhookRouter = Router(); + +const PAYPAL_BASE = + process.env.PAYPAL_ENV === 'live' + ? 'https://api-m.paypal.com' + : 'https://api-m.sandbox.paypal.com'; + +const RESERVATION_MINUTES = parseInt(process.env.RESERVATION_MINUTES || '15', 10); + +async function getAccessToken(): Promise { + const auth = Buffer.from( + `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}` + ).toString('base64'); + const resp = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: 'grant_type=client_credentials' + }); + const data = await resp.json(); + if (!resp.ok) throw new Error('paypal auth failed: ' + JSON.stringify(data)); + return data.access_token; +} + +router.post('/:id/create', async (req: Request, res: Response) => { + const itemId = req.params.id; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); + const item = rows[0]; + if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } + if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); } + if (item.status === 'reserved' && new Date(item.reserved_until) > new Date()) { + await client.query('ROLLBACK'); + return res.status(409).json({ error: 'currently reserved by another checkout' }); + } + + const token = await getAccessToken(); + const amount = (item.price_cents / 100).toFixed(2); + const orderResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + intent: 'CAPTURE', + purchase_units: [{ + custom_id: String(item.id), + description: item.name, + amount: { currency_code: process.env.SITE_CURRENCY || 'USD', value: amount } + }] + }) + }); + const order = await orderResp.json(); + if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); } + + const reservedUntil = new Date(Date.now() + RESERVATION_MINUTES * 60 * 1000); + await client.query( + `UPDATE items SET status = 'reserved', reserved_until = $1, paypal_order_id = $2 WHERE id = $3`, + [reservedUntil, order.id, item.id] + ); + await client.query('COMMIT'); + res.json({ orderID: order.id }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +router.post('/:id/capture', async (req: Request, res: Response) => { + const itemId = req.params.id; + const { orderID } = req.body; + const client = await pool.connect(); + try { + const token = await getAccessToken(); + const captureResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders/${orderID}/capture`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } + }); + const capture = await captureResp.json(); + if (!captureResp.ok || capture.status !== 'COMPLETED') { + return res.status(502).json({ error: 'capture failed', detail: capture }); + } + + await client.query('BEGIN'); + const { rows } = await client.query( + `UPDATE items SET status = 'sold', sold_at = now() + WHERE id = $1 AND paypal_order_id = $2 AND status != 'sold' + RETURNING *`, + [itemId, orderID] + ); + const capturedAmount = capture.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value; + await client.query( + `INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event) + VALUES ($1, $2, 'paypal', $3, $4, 'completed', $5)`, + [itemId, req.customerId || null, orderID, Math.round(parseFloat(capturedAmount || '0') * 100), capture] + ); + await client.query('COMMIT'); + res.json({ status: 'sold', item: rows[0] || null }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'internal error' }); + } finally { + client.release(); + } +}); + +webhookRouter.post('/', async (req: Request, res: Response) => { + try { + const token = await getAccessToken(); + const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + auth_algo: req.headers['paypal-auth-algo'], + cert_url: req.headers['paypal-cert-url'], + transmission_id: req.headers['paypal-transmission-id'], + transmission_sig: req.headers['paypal-transmission-sig'], + transmission_time: req.headers['paypal-transmission-time'], + webhook_id: process.env.PAYPAL_WEBHOOK_ID, + webhook_event: req.body + }) + }); + const verification = await verifyResp.json(); + if (verification.verification_status !== 'SUCCESS') { + console.warn('paypal webhook signature invalid'); + return res.status(400).end(); + } + + const event = req.body; + if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { + const itemId = event.resource?.custom_id; + if (itemId) { + await pool.query( + `UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 AND status != 'sold'`, + [itemId] + ); + } + } + res.status(200).end(); + } catch (err) { + console.error('webhook error', err); + res.status(500).end(); + } +}); + +export { router, webhookRouter }; diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts new file mode 100755 index 0000000..51d9d4b --- /dev/null +++ b/backend/src/routes/public.ts @@ -0,0 +1,21 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +router.get('/unsubscribe', async (req: Request, res: Response) => { + const token = req.query.token as string; + const { rows } = await pool.query(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]); + if (!rows.length) { + res.status(400).send('

Invalid or expired unsubscribe link.

'); + return; + } + await pool.query( + `UPDATE customers SET marketing_consent = false, marketing_consent_at = now(), + marketing_consent_text = 'Unsubscribed via email link' WHERE id = $1`, + [rows[0].id] + ); + res.send('

You\'ve been unsubscribed.

You will no longer receive marketing emails from Redefined Designs.

'); +}); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100755 index 0000000..64f682d --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,59 @@ +import express from 'express'; +import cookieParser from 'cookie-parser'; +import path from 'path'; +import { pool } from './db'; +import itemsRouter from './routes/items'; +import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal'; +import adminRouter from './routes/admin'; +import adminCustomersRouter from './routes/adminCustomers'; +import demoRouter from './routes/demo'; +import customersRouter from './routes/customers'; +import publicRouter from './routes/public'; +import { attachCustomer } from './middleware/customerAuth'; + +const app = express(); +app.set('trust proxy', 1); + +app.use('/webhooks/paypal', express.json(), paypalWebhookRouter); +app.use(express.json()); +app.use(cookieParser()); +app.use(attachCustomer); +app.use('/uploads', express.static('/app/uploads')); + +app.get('/api/config', (_req, res) => { + const clientId = process.env.PAYPAL_CLIENT_ID; + const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID'; + res.json({ + paypalClientId: isPlaceholder ? null : clientId, + demoMode: process.env.DEMO_MODE !== 'false', + currency: process.env.SITE_CURRENCY || 'USD' + }); +}); + +app.use('/api/items', itemsRouter); +app.use('/api/checkout/paypal', paypalRouter); +app.use('/api/checkout/demo', demoRouter); +app.use('/api/admin/customers', adminCustomersRouter); +app.use('/api/admin', adminRouter); +app.use('/api/customers', customersRouter); +app.use('/', publicRouter); + +const staticDir = path.join(__dirname, '..', 'public'); +app.use(express.static(staticDir)); +app.get('*', (_req, res) => { + res.sendFile(path.join(staticDir, 'index.html')); +}); + +setInterval(async () => { + try { + await pool.query( + `UPDATE items SET status = 'available', reserved_until = NULL, paypal_order_id = NULL + WHERE status = 'reserved' AND reserved_until < now()` + ); + } catch (err) { + console.error('reservation sweep failed:', (err as Error).message); + } +}, 60 * 1000); + +const PORT = parseInt(process.env.PORT || '3000', 10); +app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`)); diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100755 index 0000000..98b17bc --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,20 @@ +export type ItemStatus = 'available' | 'reserved' | 'sold'; + +export interface ItemImage { + id: number; + image_path: string; + sort_order: number; +} + +export interface Item { + id: number; + name: string; + description: string | null; + price_cents: number; + images: ItemImage[]; + status: ItemStatus; + reserved_until: string | null; + sold_at: string | null; + paypal_order_id: string | null; + created_at: string; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100755 index 0000000..f77b062 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 0000000..e1ef587 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + +Redefined Designs + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100755 index 0000000..86dbc71 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "redefined-designs-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc && vite build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "antd": "^5.20.6", + "@ant-design/icons": "^5.4.0", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0", + "@uiw/react-md-editor": "^4.0.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100755 index 0000000..0afce90 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState, useCallback } from 'react'; +import { Layout, Typography, Switch, Space, Row, Col, Spin, Button, theme } from 'antd'; +import { Link } from 'react-router-dom'; +import { Item, SiteConfig, fetchItems, fetchConfig } from './api'; +import ItemCard from './components/ItemCard'; +import { useThemeMode } from './theme/ThemeContext'; +import { useCustomerAuth } from './customer/CustomerAuthContext'; + +const { Header, Content, Footer } = Layout; +const { Title } = Typography; + +export default function App() { + const [items, setItems] = useState([]); + const [config, setConfig] = useState(null); + const { mode, toggle } = useThemeMode(); + const { customer } = useCustomerAuth(); + const { token } = theme.useToken(); + + const load = useCallback(() => { + fetchItems().then(setItems); + }, []); + + useEffect(() => { + load(); + fetchConfig().then(setConfig); + }, [load]); + + return ( + +
+ Redefined Designs + + + {customer ? ( + + ) : ( + <> + + + + )} + +
+ + {!config ? : ( + + {items.map(item => ( + + + + ))} + + )} + +
+ Privacy Policy +
+
+ ); +} diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx new file mode 100755 index 0000000..da3a9ea --- /dev/null +++ b/frontend/src/admin/Admin.tsx @@ -0,0 +1,221 @@ +import { useEffect, useState } from 'react'; +import { + Layout, Table, Button, Form, Input, InputNumber, Upload, Modal, + Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs +} from 'antd'; +import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'; +import type { UploadFile } from 'antd/es/upload/interface'; +import MDEditor from '@uiw/react-md-editor'; +import '@uiw/react-md-editor/markdown-editor.css'; +import '@uiw/react-markdown-preview/markdown.css'; +import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api'; +import { useThemeMode } from '../theme/ThemeContext'; +import Customers from './Customers'; + +const { Header, Content } = Layout; +const { Title } = Typography; + +function Inventory() { + const [items, setItems] = useState([]); + const [modalOpen, setModalOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form] = Form.useForm(); + const [fileList, setFileList] = useState([]); + const { mode } = useThemeMode(); + + const load = () => fetchAdminItems().then(setItems); + useEffect(() => { load(); }, []); + + function openNew() { + setEditingItem(null); + form.resetFields(); + setFileList([]); + setModalOpen(true); + } + + function openEdit(item: Item) { + setEditingItem(item); + form.setFieldsValue({ + name: item.name, + description: item.description, + price: item.price_cents / 100 + }); + setFileList([]); + setModalOpen(true); + } + + async function handleOk() { + const values = await form.validateFields(); + const fd = new FormData(); + fd.append('name', values.name); + fd.append('description', values.description || ''); + fd.append('price', String(values.price)); + fileList.forEach(f => { + if (f.originFileObj) fd.append('images', f.originFileObj as File); + }); + await saveItem(editingItem?.id ?? null, fd); + message.success(editingItem ? 'Item updated' : 'Item added'); + setModalOpen(false); + load(); + } + + async function handleDelete(id: number) { + await deleteItem(id); + message.success('Item deleted'); + load(); + } + + async function handleDeleteImage(itemId: number, imageId: number) { + await deleteItemImage(itemId, imageId); + message.success('Image removed'); + load(); + setEditingItem(prev => prev && prev.id === itemId + ? { ...prev, images: prev.images.filter(img => img.id !== imageId) } + : prev); + } + + const columns = [ + { + title: 'Image', + dataIndex: 'images', + render: (images: Item['images']) => + images[0] ? ( + + + {images.length > 1 && ( + + +{images.length - 1} + + )} + + ) : null + }, + { title: 'Name', dataIndex: 'name' }, + { + title: 'Price', + dataIndex: 'price_cents', + render: (v: number) => `$${(v / 100).toFixed(2)}` + }, + { + title: 'Status', + dataIndex: 'status', + render: (status: string) => ( + + {status.toUpperCase()} + + ) + }, + { + title: 'Actions', + render: (_: unknown, item: Item) => ( + + + + {item.status !== 'sold' + ? + : } + + ) + } + ]; + + return ( +
+
+ Inventory + +
+ + + setModalOpen(false)} + destroyOnClose + width={720} + > +
+ + + + value} + > +
+ +
+
+ + + + + {editingItem && editingItem.images.length > 0 && ( + + + {editingItem.images.map(img => ( +
+ +
+ ))} +
+
+ )} + + + false} + onChange={({ fileList }) => setFileList(fileList.slice(-6))} + maxCount={6} + multiple + listType="picture-card" + > +
Upload
+
+
+ +
+ + ); +} + +export default function Admin() { + const { mode, toggle } = useThemeMode(); + const { token } = theme.useToken(); + + return ( + +
+ Admin + +
+ + }, + { key: 'customers', label: 'Customers', children: } + ]} + /> + +
+ ); +} diff --git a/frontend/src/admin/Customers.tsx b/frontend/src/admin/Customers.tsx new file mode 100755 index 0000000..8b705ba --- /dev/null +++ b/frontend/src/admin/Customers.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from 'react'; +import { Table, Drawer, Descriptions, Tag, Typography, Spin, Empty } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { fetchCustomers, fetchCustomerDetail, CustomerSummary, CustomerDetail } from './adminCustomersApi'; + +const { Title } = Typography; + +export default function Customers() { + const [customers, setCustomers] = useState([]); + const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + fetchCustomers().then(rows => { setCustomers(rows); setLoading(false); }); + }, []); + + async function openDetail(id: number) { + setDrawerOpen(true); + setDetailLoading(true); + const data = await fetchCustomerDetail(id); + setDetail(data); + setDetailLoading(false); + } + + const columns: ColumnsType = [ + { + title: 'Customer', + dataIndex: 'email', + sorter: (a, b) => a.email.localeCompare(b.email), + render: (email: string, row: CustomerSummary) => ( +
+
{row.name || No name}
+
{email}
+
+ ) + }, + { + title: 'Verified', + dataIndex: 'email_verified', + filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }], + onFilter: (value, row) => row.email_verified === value, + render: (v: boolean) => {v ? 'Verified' : 'Unverified'} + }, + { + title: 'Subscribed', + dataIndex: 'marketing_consent', + filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }], + onFilter: (value, row) => row.marketing_consent === value, + render: (v: boolean) => {v ? 'Yes' : 'No'} + }, + { + title: 'Orders', + dataIndex: 'order_count', + sorter: (a, b) => a.order_count - b.order_count, + defaultSortOrder: 'descend' + }, + { + title: 'Total Spent', + dataIndex: 'total_spent_cents', + sorter: (a, b) => a.total_spent_cents - b.total_spent_cents, + render: (v: number) => `$${(v / 100).toFixed(2)}` + }, + { + title: 'Last Order', + dataIndex: 'last_order_at', + sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(), + render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—') + }, + { + title: 'Joined', + dataIndex: 'created_at', + sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + render: (v: string) => new Date(v).toLocaleDateString() + } + ]; + + return ( +
+ Customers +
({ onClick: () => openDetail(row.id), style: { cursor: 'pointer' } })} + pagination={{ pageSize: 10 }} + /> + + { setDrawerOpen(false); setDetail(null); }} + width={480} + > + {detailLoading || !detail ? ( + + ) : ( + <> + + {detail.customer.email} + + + {detail.customer.email_verified ? 'Verified' : 'Unverified'} + + + + + {detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'} + + {detail.customer.marketing_consent_at && ( +
+ since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()} +
+ )} +
+ + {new Date(detail.customer.created_at).toLocaleDateString()} + +
+ + Order History + {detail.orders.length === 0 ? ( + + ) : ( +
`$${(v / 100).toFixed(2)}` }, + { + title: 'Processor', + dataIndex: 'processor', + render: (v: string) => {v} + }, + { title: 'Status', dataIndex: 'status' }, + { title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() } + ]} + /> + )} + + )} + + + ); +} diff --git a/frontend/src/admin/adminCustomersApi.ts b/frontend/src/admin/adminCustomersApi.ts new file mode 100755 index 0000000..805e576 --- /dev/null +++ b/frontend/src/admin/adminCustomersApi.ts @@ -0,0 +1,44 @@ +export interface CustomerSummary { + id: number; + email: string; + name: string | null; + email_verified: boolean; + marketing_consent: boolean; + created_at: string; + order_count: number; + total_spent_cents: number; + last_order_at: string | null; +} + +export interface CustomerOrder { + id: number; + processor: string; + processor_order_id: string | null; + amount_cents: number; + status: string; + created_at: string; + item_name: string; +} + +export interface CustomerDetail { + customer: { + id: number; + email: string; + name: string | null; + email_verified: boolean; + marketing_consent: boolean; + marketing_consent_at: string | null; + created_at: string; + }; + orders: CustomerOrder[]; +} + +export async function fetchCustomers(): Promise { + const res = await fetch('/api/admin/customers'); + return res.json(); +} + +export async function fetchCustomerDetail(id: number): Promise { + const res = await fetch(`/api/admin/customers/${id}`); + return res.json(); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100755 index 0000000..d6e2e8b --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,83 @@ +export interface ItemImage { + id: number; + image_path: string; + sort_order: number; +} + +export interface Item { + id: number; + name: string; + description: string | null; + price_cents: number; + images: ItemImage[]; + status: 'available' | 'reserved' | 'sold'; +} + +export interface SiteConfig { + paypalClientId: string | null; + demoMode: boolean; + currency: string; +} + +export async function fetchConfig(): Promise { + const res = await fetch('/api/config'); + return res.json(); +} + +export async function fetchItems(): Promise { + const res = await fetch('/api/items'); + return res.json(); +} + +export async function fetchAdminItems(): Promise { + const res = await fetch('/api/admin/items'); + return res.json(); +} + +export async function saveItem(id: number | null, formData: FormData): Promise { + const url = id ? `/api/admin/items/${id}` : '/api/admin/items'; + const res = await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }); + return res.json(); +} + +export async function deleteItem(id: number): Promise { + await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }); +} + +export async function deleteItemImage(itemId: number, imageId: number): Promise { + await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }); +} + +export async function markSold(id: number): Promise { + const res = await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }); + return res.json(); +} + +export async function markAvailable(id: number): Promise { + const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }); + return res.json(); +} + +export async function createPaypalOrder(itemId: number): Promise { + const res = await fetch(`/api/checkout/paypal/${itemId}/create`, { method: 'POST' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Could not start checkout'); + return data.orderID; +} + +export async function capturePaypalOrder(itemId: number, orderID: string): Promise { + const res = await fetch(`/api/checkout/paypal/${itemId}/capture`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderID }) + }); + if (!res.ok) throw new Error('Payment captured but confirmation failed — contact the seller.'); +} + +export async function demoPurchase(itemId: number): Promise { + const res = await fetch(`/api/checkout/demo/${itemId}/purchase`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Demo purchase failed'); + } +} diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx new file mode 100755 index 0000000..15fc0af --- /dev/null +++ b/frontend/src/components/ItemCard.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from 'react'; +import { Card, Badge, Typography, Carousel, Button, message } from 'antd'; +import { LeftOutlined, RightOutlined } from '@ant-design/icons'; +import type { CarouselRef } from 'antd/es/carousel'; +import { Item, SiteConfig, createPaypalOrder, capturePaypalOrder, demoPurchase } from '../api'; +import { loadPaypalSdk } from '../paypal'; +import MarkdownView from './MarkdownView'; + +const { Text, Title } = Typography; + +declare global { + interface Window { paypal?: any; } +} + +interface Props { + item: Item; + config: SiteConfig; + onPurchased: () => void; +} + +export default function ItemCard({ item, config, onPurchased }: Props) { + const slotRef = useRef(null); + const carouselRef = useRef(null); + const [paypalReady, setPaypalReady] = useState(false); + + useEffect(() => { + if (item.status !== 'available' || !config.paypalClientId) return; + loadPaypalSdk(config.paypalClientId, config.currency) + .then(() => setPaypalReady(true)) + .catch(err => console.error(err)); + }, [item.status, config.paypalClientId, config.currency]); + + useEffect(() => { + if (!paypalReady || !window.paypal || !slotRef.current) return; + slotRef.current.innerHTML = ''; + window.paypal.Buttons({ + createOrder: () => createPaypalOrder(item.id), + onApprove: async (data: { orderID: string }) => { + try { + await capturePaypalOrder(item.id, data.orderID); + message.success('Purchase complete — thank you!'); + onPurchased(); + } catch (err) { + message.error((err as Error).message); + } + }, + onError: (err: unknown) => { + console.error(err); + message.error('Checkout error, please try again.'); + } + }).render(slotRef.current); + }, [paypalReady, item.id]); + + async function handleDemoBuy() { + try { + await demoPurchase(item.id); + message.success('Demo purchase complete — item marked sold.'); + onPurchased(); + } catch (err) { + message.error((err as Error).message); + } + } + + const hasMultiple = item.images.length > 1; + + const cover = item.images.length ? ( +
+ + {item.images.map(img => ( +
+ {item.name} +
+ ))} +
+ {hasMultiple && ( + <> +
+ ) : ( +
+ ); + + const card = ( + + {item.name} + +
${(item.price_cents / 100).toFixed(2)}
+ {item.status === 'available' && ( + <> + {config.paypalClientId &&
} + {config.demoMode && ( + + )} + + )} + + ); + + if (item.status === 'sold') { + return {card}; + } + return card; +} diff --git a/frontend/src/components/MarkdownView.tsx b/frontend/src/components/MarkdownView.tsx new file mode 100755 index 0000000..bf97b36 --- /dev/null +++ b/frontend/src/components/MarkdownView.tsx @@ -0,0 +1,15 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +interface Props { + content: string | null; +} + +export default function MarkdownView({ content }: Props) { + if (!content) return null; + return ( +
+ {content} +
+ ); +} diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx new file mode 100755 index 0000000..6ea7181 --- /dev/null +++ b/frontend/src/customer/Account.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount, logoutCustomer } from './customerApi'; +import { useCustomerAuth } from './CustomerAuthContext'; + +const { Title, Text } = Typography; + +export default function Account() { + const { customer, loading, refresh } = useCustomerAuth(); + const [orders, setOrders] = useState([]); + const navigate = useNavigate(); + + useEffect(() => { + if (customer) fetchMyOrders().then(setOrders); + }, [customer]); + + useEffect(() => { + if (!loading && !customer) navigate('/login'); + }, [loading, customer, navigate]); + + if (!customer) return null; + + async function handleConsentToggle(checked: boolean) { + await updateConsent(checked); + message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails'); + refresh(); + } + + async function handleLogout() { + await logoutCustomer(); + navigate('/'); + } + + function handleDelete() { + Modal.confirm({ + title: 'Delete your account?', + content: 'This permanently removes your account and personal data. Your past orders are kept for accounting purposes but disconnected from your identity. This cannot be undone.', + okText: 'Delete my account', + okButtonProps: { danger: true }, + onOk: async () => { + await deleteMyAccount(); + message.success('Account deleted'); + navigate('/'); + } + }); + } + + return ( +
+ + My Account + {customer.email} + {!customer.email_verified && ( +
+ Email not verified — check your inbox for a verification link. +
+ )} + + + + + Receive emails about new items + + + + Order History +
`$${(v / 100).toFixed(2)}` }, + { title: 'Processor', dataIndex: 'processor' }, + { title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() } + ]} + /> + + + + + + + + + + ); +} diff --git a/frontend/src/customer/CustomerAuthContext.tsx b/frontend/src/customer/CustomerAuthContext.tsx new file mode 100755 index 0000000..cdff708 --- /dev/null +++ b/frontend/src/customer/CustomerAuthContext.tsx @@ -0,0 +1,32 @@ +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; +import { Customer, fetchMe } from './customerApi'; + +interface CustomerAuthValue { + customer: Customer | null; + loading: boolean; + refresh: () => void; +} + +const CustomerAuthContext = createContext({ customer: null, loading: true, refresh: () => {} }); + +export function useCustomerAuth() { + return useContext(CustomerAuthContext); +} + +export function CustomerAuthProvider({ children }: { children: React.ReactNode }) { + const [customer, setCustomer] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(() => { + setLoading(true); + fetchMe().then(c => { setCustomer(c); setLoading(false); }); + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/customer/Login.tsx b/frontend/src/customer/Login.tsx new file mode 100755 index 0000000..f76055d --- /dev/null +++ b/frontend/src/customer/Login.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Form, Input, Button, Typography, Card, Alert } from 'antd'; +import { useNavigate, Link } from 'react-router-dom'; +import { loginCustomer } from './customerApi'; +import { useCustomerAuth } from './CustomerAuthContext'; + +const { Title, Text } = Typography; + +export default function Login() { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const { refresh } = useCustomerAuth(); + + async function onFinish(values: any) { + setLoading(true); + setError(null); + try { + await loginCustomer(values.email, values.password); + refresh(); + navigate('/account'); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( +
+ + Log in + {error && } +
+ + + + + + + + + + + + No account yet? Create one + +
+
+ ); +} diff --git a/frontend/src/customer/PrivacyPolicy.tsx b/frontend/src/customer/PrivacyPolicy.tsx new file mode 100755 index 0000000..7edb54f --- /dev/null +++ b/frontend/src/customer/PrivacyPolicy.tsx @@ -0,0 +1,50 @@ +import { Typography, Card } from 'antd'; + +const { Title, Paragraph } = Typography; + +export default function PrivacyPolicy() { + return ( +
+ + Privacy Policy + + This is a general-purpose starter policy. Have it reviewed by a lawyer before relying on it — + it is not legal advice and may not reflect your specific obligations. + + + What we collect + + If you create an account, we collect your email address, name (optional), and a securely + hashed password. If you make a purchase, we record the item, amount, and payment processor + transaction reference. We do not store your card or PayPal login details — payment is handled + entirely by our payment processor. + + + Marketing emails + + We only send you marketing emails if you explicitly opt in during signup or in your account + settings. You can withdraw consent at any time from your account page, or via the unsubscribe + link included in every marketing email — no login required. + + + Your rights + + You may request a copy of your data ("Download my data" in your account page), or delete your + account entirely at any time. Deleting your account removes your personal information; past + order records are retained in anonymized form for accounting purposes. + + + Data retention + + Account data is retained until you delete your account. Order records are retained as required + for financial recordkeeping, disconnected from your identity upon account deletion. + + + Contact + + For privacy questions or data requests, contact us at the email address listed on this site. + + +
+ ); +} diff --git a/frontend/src/customer/Register.tsx b/frontend/src/customer/Register.tsx new file mode 100755 index 0000000..ea17815 --- /dev/null +++ b/frontend/src/customer/Register.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { Form, Input, Button, Checkbox, Typography, Card, Alert } from 'antd'; +import { useNavigate, Link } from 'react-router-dom'; +import { registerCustomer } from './customerApi'; +import { useCustomerAuth } from './CustomerAuthContext'; + +const { Title, Text } = Typography; + +export default function Register() { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const { refresh } = useCustomerAuth(); + + async function onFinish(values: any) { + setLoading(true); + setError(null); + try { + await registerCustomer(values.email, values.password, values.name, !!values.marketingConsent); + refresh(); + navigate('/account'); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + } + + return ( +
+ + Create an account + {error && } +
+ + + + + + + + + + + + Send me occasional emails about new one-of-a-kind items. I can unsubscribe at any time. + + + + + + + + Already have an account? Log in + +
+ + By creating an account you agree to our Privacy Policy. + +
+
+ ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts new file mode 100755 index 0000000..48145ba --- /dev/null +++ b/frontend/src/customer/customerApi.ts @@ -0,0 +1,69 @@ +export interface Customer { + id: number; + email: string; + name: string | null; + email_verified: boolean; + marketing_consent: boolean; + created_at: string; +} + +export interface OrderHistoryItem { + id: number; + processor: string; + amount_cents: number; + status: string; + created_at: string; + item_name: string; +} + +async function handle(res: Response): Promise { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Request failed'); + } + return res.json(); +} + +export function registerCustomer(email: string, password: string, name: string, marketingConsent: boolean): Promise { + return fetch('/api/customers/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name, marketingConsent }) + }).then(res => handle(res)); +} + +export function loginCustomer(email: string, password: string): Promise { + return fetch('/api/customers/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }).then(res => handle(res)); +} + +export function logoutCustomer(): Promise { + return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined); +} + +export function fetchMe(): Promise { + return fetch('/api/customers/me').then(res => (res.ok ? res.json() : null)); +} + +export function updateConsent(marketingConsent: boolean): Promise { + return fetch('/api/customers/me/consent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ marketingConsent }) + }).then(() => undefined); +} + +export function fetchMyOrders(): Promise { + return fetch('/api/customers/me/orders').then(res => handle(res)); +} + +export function deleteMyAccount(): Promise { + return fetch('/api/customers/me', { method: 'DELETE' }).then(() => undefined); +} + +export function exportMyData(): void { + window.location.href = '/api/customers/me/export'; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100755 index 0000000..d624ea7 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { ConfigProvider, theme as antdTheme } from 'antd'; +import 'antd/dist/reset.css'; +import App from './App'; +import Admin from './admin/Admin'; +import Login from './customer/Login'; +import Register from './customer/Register'; +import Account from './customer/Account'; +import PrivacyPolicy from './customer/PrivacyPolicy'; +import { CustomerAuthProvider } from './customer/CustomerAuthContext'; +import { ThemeModeProvider, useThemeMode } from './theme/ThemeContext'; +import './styles.css'; + +function Root() { + const { mode } = useThemeMode(); + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/frontend/src/paypal.ts b/frontend/src/paypal.ts new file mode 100755 index 0000000..7aeb5c5 --- /dev/null +++ b/frontend/src/paypal.ts @@ -0,0 +1,14 @@ +let loadPromise: Promise | null = null; + +export function loadPaypalSdk(clientId: string, currency: string): Promise { + if ((window as any).paypal) return Promise.resolve(); + if (loadPromise) return loadPromise; + loadPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = `https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}¤cy=${currency}`; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('failed to load paypal sdk')); + document.head.appendChild(script); + }); + return loadPromise; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100755 index 0000000..1e2fef5 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,33 @@ +body { margin: 0; } + +.item-card .card-cover-img { width: 100%; height: 220px; object-fit: cover; display: block; } +.item-card .card-cover-placeholder { width: 100%; height: 220px; background: #eee; } +.price { font-weight: 600; margin: 8px 0; font-size: 16px; } +.paypal-slot { margin-top: 8px; } + +.carousel-wrap { position: relative; } +.carousel-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + opacity: 0.85; +} +.carousel-arrow-left { left: 8px; } +.carousel-arrow-right { right: 8px; } +.carousel-count { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0,0,0,0.6); + color: #fff; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + z-index: 2; +} + +.markdown-body { font-size: 14px; } +.markdown-body p { margin: 4px 0; } +.markdown-body ul, .markdown-body ol { margin: 4px 0; padding-left: 20px; } +.markdown-body h1, .markdown-body h2, .markdown-body h3 { font-size: 15px; margin: 8px 0 4px; } diff --git a/frontend/src/theme/ThemeContext.tsx b/frontend/src/theme/ThemeContext.tsx new file mode 100755 index 0000000..1f0f878 --- /dev/null +++ b/frontend/src/theme/ThemeContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; + +type ThemeMode = 'light' | 'dark'; + +interface ThemeContextValue { + mode: ThemeMode; + toggle: () => void; +} + +const ThemeContext = createContext({ mode: 'light', toggle: () => {} }); + +export function useThemeMode() { + return useContext(ThemeContext); +} + +const STORAGE_KEY = 'redefined-designs-theme'; + +function getInitialMode(): ThemeMode { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; +} + +export function ThemeModeProvider({ children }: { children: React.ReactNode }) { + const [mode, setMode] = useState(getInitialMode); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, mode); + document.body.setAttribute('data-theme', mode); + }, [mode]); + + const value = useMemo( + () => ({ mode, toggle: () => setMode(m => (m === 'light' ? 'dark' : 'light')) }), + [mode] + ); + + return {children}; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100755 index 0000000..8b0351e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100755 index 0000000..5fee0e5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { outDir: 'dist' } +});