Initial commit: redefined-designs storefront

This commit is contained in:
2026-08-13 21:07:54 +00:00
commit 9be4986dd3
38 changed files with 2121 additions and 0 deletions
Executable
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
frontend/dist/
backend/dist/
*.log
.DS_Store
uploads/
Executable
+23
View File
@@ -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"]
+59
View File
@@ -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()
);
+28
View File
@@ -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"
}
}
+9
View File
@@ -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
});
+24
View File
@@ -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<void> {
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
});
}
+29
View File
@@ -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<void> {
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();
}
+124
View File
@@ -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;
+40
View File
@@ -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;
+186
View File
@@ -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<string> {
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',
`<p>Welcome! Please <a href="${verifyUrl}">verify your email</a> to finish setting up your account.</p>`
).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;
+39
View File
@@ -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;
+28
View File
@@ -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;
+156
View File
@@ -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<string> {
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 };
+21
View File
@@ -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('<html><body><h2>Invalid or expired unsubscribe link.</h2></body></html>');
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('<html><body><h2>You\'ve been unsubscribed.</h2><p>You will no longer receive marketing emails from Redefined Designs.</p></body></html>');
});
export default router;
+59
View File
@@ -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}`));
+20
View File
@@ -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;
}
+14
View File
@@ -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"]
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redefined Designs</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -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"
}
}
+68
View File
@@ -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<Item[]>([]);
const [config, setConfig] = useState<SiteConfig | null>(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 (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}
>
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Redefined Designs</Title>
<Space>
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
{customer ? (
<Link to="/account"><Button>My Account</Button></Link>
) : (
<>
<Link to="/login"><Button>Log in</Button></Link>
<Link to="/register"><Button type="primary">Sign up</Button></Link>
</>
)}
</Space>
</Header>
<Content style={{ padding: 24 }}>
{!config ? <Spin /> : (
<Row gutter={[20, 20]}>
{items.map(item => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<ItemCard item={item} config={config} onPurchased={load} />
</Col>
))}
</Row>
)}
</Content>
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
<Link to="/privacy">Privacy Policy</Link>
</Footer>
</Layout>
);
}
+221
View File
@@ -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<Item[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<Item | null>(null);
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
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] ? (
<span style={{ position: 'relative', display: 'inline-block' }}>
<img src={images[0].image_path} style={{ width: 60 }} />
{images.length > 1 && (
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>
+{images.length - 1}
</Tag>
)}
</span>
) : null
},
{ title: 'Name', dataIndex: 'name' },
{
title: 'Price',
dataIndex: 'price_cents',
render: (v: number) => `$${(v / 100).toFixed(2)}`
},
{
title: 'Status',
dataIndex: 'status',
render: (status: string) => (
<Tag color={status === 'sold' ? 'red' : status === 'reserved' ? 'orange' : 'green'}>
{status.toUpperCase()}
</Tag>
)
},
{
title: 'Actions',
render: (_: unknown, item: Item) => (
<Space>
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
{item.status !== 'sold'
? <Button size="small" onClick={() => markSold(item.id).then(load)}>Mark Sold</Button>
: <Button size="small" onClick={() => markAvailable(item.id).then(load)}>Mark Available</Button>}
</Space>
)
}
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
<Button type="primary" onClick={openNew}>Add Item</Button>
</div>
<Table rowKey="id" dataSource={items} columns={columns} />
<Modal
title={editingItem ? 'Edit Item' : 'Add Item'}
open={modalOpen}
onOk={handleOk}
onCancel={() => setModalOpen(false)}
destroyOnClose
width={720}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="description"
label="Description (Markdown supported)"
getValueFromEvent={(value) => value}
>
<div data-color-mode={mode}>
<MDEditor height={220} preview="live" />
</div>
</Form.Item>
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
{editingItem && editingItem.images.length > 0 && (
<Form.Item label="Existing Images (front / back / etc.)">
<Space wrap>
{editingItem.images.map(img => (
<div key={img.id} style={{ position: 'relative' }}>
<AntImage src={img.image_path} width={80} height={80} style={{ objectFit: 'cover' }} />
<Button
size="small"
danger
icon={<DeleteOutlined />}
style={{ position: 'absolute', top: 0, right: 0 }}
onClick={() => handleDeleteImage(editingItem.id, img.id)}
/>
</div>
))}
</Space>
</Form.Item>
)}
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
<Upload
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList.slice(-6))}
maxCount={6}
multiple
listType="picture-card"
>
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
</Upload>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default function Admin() {
const { mode, toggle } = useThemeMode();
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: token.colorBgContainer,
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}
>
<Title level={3} style={{ color: token.colorText, margin: 0 }}>Admin</Title>
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
</Header>
<Content style={{ padding: 24 }}>
<Tabs
defaultActiveKey="inventory"
items={[
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
{ key: 'customers', label: 'Customers', children: <Customers /> }
]}
/>
</Content>
</Layout>
);
}
+150
View File
@@ -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<CustomerSummary[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<CustomerDetail | null>(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<CustomerSummary> = [
{
title: 'Customer',
dataIndex: 'email',
sorter: (a, b) => a.email.localeCompare(b.email),
render: (email: string, row: CustomerSummary) => (
<div>
<div>{row.name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
</div>
)
},
{
title: 'Verified',
dataIndex: 'email_verified',
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
onFilter: (value, row) => row.email_verified === value,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? 'Verified' : 'Unverified'}</Tag>
},
{
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) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
},
{
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 (
<div>
<Title level={4}>Customers</Title>
<Table
rowKey="id"
loading={loading}
dataSource={customers}
columns={columns}
onRow={row => ({ onClick: () => openDetail(row.id), style: { cursor: 'pointer' } })}
pagination={{ pageSize: 10 }}
/>
<Drawer
title={detail?.customer.name || detail?.customer.email || 'Customer'}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setDetail(null); }}
width={480}
>
{detailLoading || !detail ? (
<Spin />
) : (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
<Descriptions.Item label="Verified">
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Marketing consent">
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
</Tag>
{detail.customer.marketing_consent_at && (
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
</div>
)}
</Descriptions.Item>
<Descriptions.Item label="Joined">
{new Date(detail.customer.created_at).toLocaleDateString()}
</Descriptions.Item>
</Descriptions>
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
{detail.orders.length === 0 ? (
<Empty description="No orders yet" />
) : (
<Table
rowKey="id"
size="small"
dataSource={detail.orders}
pagination={false}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{
title: 'Processor',
dataIndex: 'processor',
render: (v: string) => <Tag>{v}</Tag>
},
{ title: 'Status', dataIndex: 'status' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
)}
</>
)}
</Drawer>
</div>
);
}
+44
View File
@@ -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<CustomerSummary[]> {
const res = await fetch('/api/admin/customers');
return res.json();
}
export async function fetchCustomerDetail(id: number): Promise<CustomerDetail> {
const res = await fetch(`/api/admin/customers/${id}`);
return res.json();
}
+83
View File
@@ -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<SiteConfig> {
const res = await fetch('/api/config');
return res.json();
}
export async function fetchItems(): Promise<Item[]> {
const res = await fetch('/api/items');
return res.json();
}
export async function fetchAdminItems(): Promise<Item[]> {
const res = await fetch('/api/admin/items');
return res.json();
}
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
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<void> {
await fetch(`/api/admin/items/${id}`, { method: 'DELETE' });
}
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' });
}
export async function markSold(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' });
return res.json();
}
export async function markAvailable(id: number): Promise<Item> {
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
return res.json();
}
export async function createPaypalOrder(itemId: number): Promise<string> {
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<void> {
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<void> {
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');
}
}
+126
View File
@@ -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<HTMLDivElement>(null);
const carouselRef = useRef<CarouselRef>(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 ? (
<div className="carousel-wrap">
<Carousel ref={carouselRef} dots={hasMultiple}>
{item.images.map(img => (
<div key={img.id}>
<img src={img.image_path} alt={item.name} className="card-cover-img" />
</div>
))}
</Carousel>
{hasMultiple && (
<>
<Button
className="carousel-arrow carousel-arrow-left"
shape="circle"
size="small"
icon={<LeftOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }}
/>
<Button
className="carousel-arrow carousel-arrow-right"
shape="circle"
size="small"
icon={<RightOutlined />}
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }}
/>
<div className="carousel-count">{item.images.length} photos</div>
</>
)}
</div>
) : (
<div className="card-cover-placeholder" />
);
const card = (
<Card hoverable cover={cover} className="item-card">
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
<MarkdownView content={item.description} />
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
{item.status === 'available' && (
<>
{config.paypalClientId && <div className="paypal-slot" ref={slotRef} />}
{config.demoMode && (
<Button
block
type={config.paypalClientId ? 'default' : 'primary'}
onClick={handleDemoBuy}
style={{ marginTop: 8 }}
>
{config.paypalClientId ? 'Buy Now (Demo)' : 'Buy Now'}
</Button>
)}
</>
)}
</Card>
);
if (item.status === 'sold') {
return <Badge.Ribbon text="SOLD" color="black">{card}</Badge.Ribbon>;
}
return card;
}
+15
View File
@@ -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 (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
+90
View File
@@ -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<OrderHistoryItem[]>([]);
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 (
<div style={{ maxWidth: 700, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>My Account</Title>
<Text>{customer.email}</Text>
{!customer.email_verified && (
<div style={{ marginTop: 8 }}>
<Text type="warning">Email not verified check your inbox for a verification link.</Text>
</div>
)}
<Divider />
<Space align="center">
<Switch checked={customer.marketing_consent} onChange={handleConsentToggle} />
<Text>Receive emails about new items</Text>
</Space>
<Divider />
<Title level={5}>Order History</Title>
<Table
rowKey="id"
size="small"
dataSource={orders}
pagination={false}
columns={[
{ title: 'Item', dataIndex: 'item_name' },
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{ title: 'Processor', dataIndex: 'processor' },
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
]}
/>
<Divider />
<Space wrap>
<Button onClick={exportMyData}>Download my data</Button>
<Button onClick={handleLogout}>Log out</Button>
<Button danger onClick={handleDelete}>Delete my account</Button>
</Space>
</Card>
</div>
);
}
+32
View File
@@ -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<CustomerAuthValue>({ customer: null, loading: true, refresh: () => {} });
export function useCustomerAuth() {
return useContext(CustomerAuthContext);
}
export function CustomerAuthProvider({ children }: { children: React.ReactNode }) {
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
setLoading(true);
fetchMe().then(c => { setCustomer(c); setLoading(false); });
}, []);
useEffect(() => { refresh(); }, [refresh]);
return (
<CustomerAuthContext.Provider value={{ customer, loading, refresh }}>
{children}
</CustomerAuthContext.Provider>
);
}
+51
View File
@@ -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<string | null>(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 (
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Log in</Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Log in</Button>
</Form.Item>
</Form>
<Text type="secondary">
No account yet? <Link to="/register">Create one</Link>
</Text>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { Typography, Card } from 'antd';
const { Title, Paragraph } = Typography;
export default function PrivacyPolicy() {
return (
<div style={{ maxWidth: 720, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={2}>Privacy Policy</Title>
<Paragraph type="secondary">
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.
</Paragraph>
<Title level={4}>What we collect</Title>
<Paragraph>
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.
</Paragraph>
<Title level={4}>Marketing emails</Title>
<Paragraph>
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.
</Paragraph>
<Title level={4}>Your rights</Title>
<Paragraph>
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.
</Paragraph>
<Title level={4}>Data retention</Title>
<Paragraph>
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.
</Paragraph>
<Title level={4}>Contact</Title>
<Paragraph>
For privacy questions or data requests, contact us at the email address listed on this site.
</Paragraph>
</Card>
</div>
);
}
+63
View File
@@ -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<string | null>(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 (
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
<Card>
<Title level={3}>Create an account</Title>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish}>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>
Send me occasional emails about new one-of-a-kind items. I can unsubscribe at any time.
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>Create account</Button>
</Form.Item>
</Form>
<Text type="secondary">
Already have an account? <Link to="/login">Log in</Link>
</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
By creating an account you agree to our <Link to="/privacy">Privacy Policy</Link>.
</Text>
</Card>
</div>
);
}
+69
View File
@@ -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<T>(res: Response): Promise<T> {
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<Customer> {
return fetch('/api/customers/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, marketingConsent })
}).then(res => handle<Customer>(res));
}
export function loginCustomer(email: string, password: string): Promise<Customer> {
return fetch('/api/customers/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
}).then(res => handle<Customer>(res));
}
export function logoutCustomer(): Promise<void> {
return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined);
}
export function fetchMe(): Promise<Customer | null> {
return fetch('/api/customers/me').then(res => (res.ok ? res.json() : null));
}
export function updateConsent(marketingConsent: boolean): Promise<void> {
return fetch('/api/customers/me/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ marketingConsent })
}).then(() => undefined);
}
export function fetchMyOrders(): Promise<OrderHistoryItem[]> {
return fetch('/api/customers/me/orders').then(res => handle<OrderHistoryItem[]>(res));
}
export function deleteMyAccount(): Promise<void> {
return fetch('/api/customers/me', { method: 'DELETE' }).then(() => undefined);
}
export function exportMyData(): void {
window.location.href = '/api/customers/me/export';
}
+47
View File
@@ -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 (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: { colorPrimary: '#1a1a1a' }
}}
>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/admin" element={<Admin />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/account" element={<Account />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeModeProvider>
<CustomerAuthProvider>
<Root />
</CustomerAuthProvider>
</ThemeModeProvider>
</React.StrictMode>
);
+14
View File
@@ -0,0 +1,14 @@
let loadPromise: Promise<void> | null = null;
export function loadPaypalSdk(clientId: string, currency: string): Promise<void> {
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)}&currency=${currency}`;
script.onload = () => resolve();
script.onerror = () => reject(new Error('failed to load paypal sdk'));
document.head.appendChild(script);
});
return loadPromise;
}
+33
View File
@@ -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; }
+40
View File
@@ -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<ThemeContextValue>({ 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<ThemeMode>(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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
+15
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist' }
});