shop.tonybtw.com
shop.tonybtw.com
https://git.tonybtw.com/shop.tonybtw.com.git
git://git.tonybtw.com/shop.tonybtw.com.git
initial commit
Diff
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b07ab7c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.direnv/
+.pgdata/
+.env
+*.log
+notes/
diff --git a/app/controllers/Cart_Controller.php b/app/controllers/Cart_Controller.php
new file mode 100644
index 0000000..ed5d61e
--- /dev/null
+++ b/app/controllers/Cart_Controller.php
@@ -0,0 +1,61 @@
+<?php
+
+class Cart_Controller {
+ public function index(array $params): void {
+ $cart = Cart_Model::get();
+ $items = Cart_Model::get_items_with_details($cart);
+ require APP_ROOT . '/app/views/cart.php';
+ }
+
+ public function add(array $params): void {
+ $product_id = (int) $_POST['product_id'];
+ $variant_id = (int) $_POST['variant_id'];
+ $quantity = max(1, (int) ($_POST['quantity'] ?? 1));
+
+ Cart_Model::add($product_id, $variant_id, $quantity);
+
+ if (is_htmx_request()) {
+ require APP_ROOT . '/app/views/partials/cart_widget.php';
+ return;
+ }
+
+ redirect($_SERVER['HTTP_REFERER'] ?? '/');
+ }
+
+ public function update(array $params): void {
+ $product_id = (int) $_POST['product_id'];
+ $variant_id = (int) $_POST['variant_id'];
+ $quantity = max(0, (int) $_POST['quantity']);
+
+ if ($quantity === 0) {
+ Cart_Model::remove($product_id, $variant_id);
+ } else {
+ Cart_Model::update($product_id, $variant_id, $quantity);
+ }
+
+ if (is_htmx_request()) {
+ $cart = Cart_Model::get();
+ $items = Cart_Model::get_items_with_details($cart);
+ require APP_ROOT . '/app/views/partials/cart_items.php';
+ return;
+ }
+
+ redirect('/cart');
+ }
+
+ public function remove(array $params): void {
+ $product_id = (int) $_POST['product_id'];
+ $variant_id = (int) $_POST['variant_id'];
+
+ Cart_Model::remove($product_id, $variant_id);
+
+ if (is_htmx_request()) {
+ $cart = Cart_Model::get();
+ $items = Cart_Model::get_items_with_details($cart);
+ require APP_ROOT . '/app/views/partials/cart_items.php';
+ return;
+ }
+
+ redirect('/cart');
+ }
+}
diff --git a/app/controllers/Checkout_Controller.php b/app/controllers/Checkout_Controller.php
new file mode 100644
index 0000000..a2bb06f
--- /dev/null
+++ b/app/controllers/Checkout_Controller.php
@@ -0,0 +1,56 @@
+<?php
+
+class Checkout_Controller {
+ public function index(array $params): void {
+ $cart = Cart_Model::get();
+
+ if (empty($cart)) {
+ redirect('/cart');
+ return;
+ }
+
+ $items = Cart_Model::get_items_with_details($cart);
+ $total = Cart_Model::get_total($items);
+ require APP_ROOT . '/app/views/checkout.php';
+ }
+
+ public function create(array $params): void {
+ $cart = Cart_Model::get();
+
+ if (empty($cart)) {
+ redirect('/cart');
+ return;
+ }
+
+ $items = Cart_Model::get_items_with_details($cart);
+ $total = Cart_Model::get_total($items);
+
+ $line_items = array_map(function($item) {
+ return [
+ 'price_data' => [
+ 'currency' => 'usd',
+ 'unit_amount' => $item['price'],
+ 'product_data' => [
+ 'name' => $item['name'] . ' (' . $item['size'] . ')',
+ ],
+ ],
+ 'quantity' => $item['quantity'],
+ ];
+ }, $items);
+
+ $session = stripe_create_checkout_session($line_items);
+
+ if (!$session) {
+ $error = 'Failed to create checkout session';
+ require APP_ROOT . '/app/views/error.php';
+ return;
+ }
+
+ redirect($session['url']);
+ }
+
+ public function success(array $params): void {
+ Cart_Model::clear();
+ require APP_ROOT . '/app/views/success.php';
+ }
+}
diff --git a/app/controllers/Shop_Controller.php b/app/controllers/Shop_Controller.php
new file mode 100644
index 0000000..3556534
--- /dev/null
+++ b/app/controllers/Shop_Controller.php
@@ -0,0 +1,22 @@
+<?php
+
+class Shop_Controller {
+ public function index(array $params): void {
+ $products = Product_Model::get_all();
+ require APP_ROOT . '/app/views/home.php';
+ }
+
+ public function product(array $params): void {
+ $product = Product_Model::get_by_slug($params['slug']);
+
+ if (!$product) {
+ http_response_code(404);
+ $error = 'Product not found';
+ require APP_ROOT . '/app/views/error.php';
+ return;
+ }
+
+ $variants = Product_Model::get_variants($product['id']);
+ require APP_ROOT . '/app/views/product.php';
+ }
+}
diff --git a/app/controllers/Webhook_Controller.php b/app/controllers/Webhook_Controller.php
new file mode 100644
index 0000000..64557b6
--- /dev/null
+++ b/app/controllers/Webhook_Controller.php
@@ -0,0 +1,44 @@
+<?php
+
+class Webhook_Controller {
+ public function stripe(array $params): void {
+ $payload = file_get_contents('php://input');
+ $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
+
+ $event = stripe_verify_webhook($payload, $sig_header);
+
+ if (!$event) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Invalid signature']);
+ return;
+ }
+
+ switch ($event['type']) {
+ case 'checkout.session.completed':
+ $this->handle_checkout_complete($event['data']['object']);
+ break;
+ }
+
+ http_response_code(200);
+ echo json_encode(['received' => true]);
+ }
+
+ private function handle_checkout_complete(array $session): void {
+ $order_id = Order_Model::create([
+ 'stripe_session_id' => $session['id'],
+ 'stripe_payment_intent' => $session['payment_intent'],
+ 'email' => $session['customer_details']['email'],
+ 'total' => $session['amount_total'],
+ 'status' => 'paid',
+ ]);
+
+ $printful_order = printful_create_order($order_id);
+
+ if ($printful_order) {
+ Order_Model::update($order_id, [
+ 'printful_order_id' => $printful_order['id'],
+ 'status' => 'fulfilled',
+ ]);
+ }
+ }
+}
diff --git a/app/lib/helpers.php b/app/lib/helpers.php
new file mode 100644
index 0000000..5da600b
--- /dev/null
+++ b/app/lib/helpers.php
@@ -0,0 +1,33 @@
+<?php
+
+function h(string $str): string {
+ return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
+}
+
+function price(int $cents): string {
+ return '$' . number_format($cents / 100, 2);
+}
+
+function redirect(string $url): void {
+ header('Location: ' . $url);
+ exit;
+}
+
+function is_htmx_request(): bool {
+ return isset($_SERVER['HTTP_HX_REQUEST']);
+}
+
+function csrf_token(): string {
+ if (!isset($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+ return $_SESSION['csrf_token'];
+}
+
+function csrf_field(): string {
+ return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
+}
+
+function verify_csrf(): bool {
+ return isset($_POST['csrf_token']) && hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token']);
+}
diff --git a/app/lib/printful.php b/app/lib/printful.php
new file mode 100644
index 0000000..b2bfc1f
--- /dev/null
+++ b/app/lib/printful.php
@@ -0,0 +1,86 @@
+<?php
+
+function printful_request(string $method, string $endpoint, array $data = []): ?array {
+ $ch = curl_init();
+
+ $url = 'https://api.printful.com' . $endpoint;
+
+ $headers = [
+ 'Authorization: Bearer ' . PRINTFUL_API_KEY,
+ 'Content-Type: application/json',
+ ];
+
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
+ }
+
+ $response = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($status >= 400) {
+ error_log("Printful API error: $status - $response");
+ return null;
+ }
+
+ $result = json_decode($response, true);
+ return $result['result'] ?? null;
+}
+
+function printful_create_order(int $order_id): ?array {
+ $order = Order_Model::get_by_id($order_id);
+ $items = Order_Model::get_items($order_id);
+
+ if (!$order || empty($items)) {
+ return null;
+ }
+
+ $shipping = json_decode($order['shipping_address'], true);
+
+ $printful_items = [];
+ foreach ($items as $item) {
+ $variant = Product_Model::get_variant($item['variant_id']);
+ if ($variant && $variant['printful_variant_id']) {
+ $printful_items[] = [
+ 'variant_id' => (int) $variant['printful_variant_id'],
+ 'quantity' => $item['quantity'],
+ ];
+ }
+ }
+
+ if (empty($printful_items)) {
+ return null;
+ }
+
+ $data = [
+ 'external_id' => (string) $order_id,
+ 'recipient' => [
+ 'name' => $shipping['name'] ?? $order['shipping_name'],
+ 'address1' => $shipping['line1'] ?? '',
+ 'address2' => $shipping['line2'] ?? '',
+ 'city' => $shipping['city'] ?? '',
+ 'state_code' => $shipping['state'] ?? '',
+ 'country_code' => $shipping['country'] ?? 'US',
+ 'zip' => $shipping['postal_code'] ?? '',
+ 'email' => $order['email'],
+ ],
+ 'items' => $printful_items,
+ ];
+
+ return printful_request('POST', '/orders', $data);
+}
+
+function printful_get_products(): ?array {
+ return printful_request('GET', '/store/products');
+}
+
+function printful_get_product(int $product_id): ?array {
+ return printful_request('GET', '/store/products/' . $product_id);
+}
diff --git a/app/lib/stripe.php b/app/lib/stripe.php
new file mode 100644
index 0000000..1d4281f
--- /dev/null
+++ b/app/lib/stripe.php
@@ -0,0 +1,77 @@
+<?php
+
+function stripe_request(string $method, string $endpoint, array $data = []): ?array {
+ $ch = curl_init();
+
+ $url = 'https://api.stripe.com/v1' . $endpoint;
+
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':',
+ CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
+ ]);
+
+ if ($method === 'POST') {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
+ }
+
+ $response = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($status >= 400) {
+ error_log("Stripe API error: $status - $response");
+ return null;
+ }
+
+ return json_decode($response, true);
+}
+
+function stripe_create_checkout_session(array $line_items): ?array {
+ $data = [
+ 'mode' => 'payment',
+ 'success_url' => SITE_URL . '/success?session_id={CHECKOUT_SESSION_ID}',
+ 'cancel_url' => SITE_URL . '/cart',
+ 'shipping_address_collection' => ['allowed_countries' => ['US', 'CA']],
+ ];
+
+ foreach ($line_items as $i => $item) {
+ $data["line_items[$i][price_data][currency]"] = $item['price_data']['currency'];
+ $data["line_items[$i][price_data][unit_amount]"] = $item['price_data']['unit_amount'];
+ $data["line_items[$i][price_data][product_data][name]"] = $item['price_data']['product_data']['name'];
+ $data["line_items[$i][quantity]"] = $item['quantity'];
+ }
+
+ return stripe_request('POST', '/checkout/sessions', $data);
+}
+
+function stripe_verify_webhook(string $payload, string $sig_header): ?array {
+ $elements = explode(',', $sig_header);
+ $timestamp = null;
+ $signature = null;
+
+ foreach ($elements as $element) {
+ [$key, $value] = explode('=', $element, 2);
+ if ($key === 't') $timestamp = $value;
+ if ($key === 'v1') $signature = $value;
+ }
+
+ if (!$timestamp || !$signature) {
+ return null;
+ }
+
+ $signed_payload = $timestamp . '.' . $payload;
+ $expected = hash_hmac('sha256', $signed_payload, STRIPE_WEBHOOK_SECRET);
+
+ if (!hash_equals($expected, $signature)) {
+ return null;
+ }
+
+ if (abs(time() - (int)$timestamp) > 300) {
+ return null;
+ }
+
+ return json_decode($payload, true);
+}
diff --git a/app/models/Cart_Model.php b/app/models/Cart_Model.php
new file mode 100644
index 0000000..8f99a31
--- /dev/null
+++ b/app/models/Cart_Model.php
@@ -0,0 +1,69 @@
+<?php
+
+class Cart_Model {
+ public static function get(): array {
+ return $_SESSION['cart'] ?? [];
+ }
+
+ public static function add(int $product_id, int $variant_id, int $quantity): void {
+ if (!isset($_SESSION['cart'])) {
+ $_SESSION['cart'] = [];
+ }
+
+ $key = "{$product_id}_{$variant_id}";
+ if (isset($_SESSION['cart'][$key])) {
+ $_SESSION['cart'][$key]['quantity'] += $quantity;
+ } else {
+ $_SESSION['cart'][$key] = [
+ 'product_id' => $product_id,
+ 'variant_id' => $variant_id,
+ 'quantity' => $quantity,
+ ];
+ }
+ }
+
+ public static function update(int $product_id, int $variant_id, int $quantity): void {
+ $key = "{$product_id}_{$variant_id}";
+ if (isset($_SESSION['cart'][$key])) {
+ $_SESSION['cart'][$key]['quantity'] = $quantity;
+ }
+ }
+
+ public static function remove(int $product_id, int $variant_id): void {
+ $key = "{$product_id}_{$variant_id}";
+ unset($_SESSION['cart'][$key]);
+ }
+
+ public static function clear(): void {
+ $_SESSION['cart'] = [];
+ }
+
+ public static function count(): int {
+ $cart = self::get();
+ return array_sum(array_column($cart, 'quantity'));
+ }
+
+ public static function get_items_with_details(array $cart): array {
+ $items = [];
+ foreach ($cart as $item) {
+ $variant = Product_Model::get_variant($item['variant_id']);
+ if ($variant) {
+ $items[] = [
+ 'product_id' => $item['product_id'],
+ 'variant_id' => $item['variant_id'],
+ 'quantity' => $item['quantity'],
+ 'name' => $variant['product_name'],
+ 'size' => $variant['size'],
+ 'price' => $variant['price'],
+ 'image_url' => $variant['image_url'],
+ 'subtotal' => $variant['price'] * $item['quantity'],
+ ];
+ }
+ }
+ return $items;
+ }
+
+ public static function get_total(array $items): int {
+ return array_sum(array_column($items, 'subtotal'));
+ }
+}
diff --git a/app/models/Order_Model.php b/app/models/Order_Model.php
new file mode 100644
index 0000000..c478c3c
--- /dev/null
+++ b/app/models/Order_Model.php
@@ -0,0 +1,100 @@
+<?php
+
+class Order_Model {
+ public static function create_order(
+ string $stripe_session_id,
+ string $stripe_payment_intent,
+ string $email,
+ int $total,
+ string $status,
+ string $shipping_name,
+ string $shipping_address
+ ): int {
+ $db = get_db();
+ $sql = <<<SQL
+ INSERT INTO orders (
+ stripe_session_id,
+ stripe_payment_intent,
+ email,
+ total,
+ status,
+ shipping_name,
+ shipping_address
+ )
+ VALUES (
+ :stripe_session_id,
+ :stripe_payment_intent,
+ :email,
+ :total,
+ :status,
+ :shipping_name,
+ :shipping_address
+ )
+ RETURNING id
+ SQL;
+ $sth = $db->prepare($sql);
+ $sth->execute([
+ 'stripe_session_id' => $stripe_session_id,
+ 'stripe_payment_intent' => $stripe_payment_intent,
+ 'email' => $email,
+ 'total' => $total,
+ 'status' => $status,
+ 'shipping_name' => $shipping_name,
+ 'shipping_address' => $shipping_address,
+ ]);
+ return $sth->fetchColumn();
+ }
+
+ public static function get_by_id(int $id): ?array {
+ $db = get_db();
+ $stmt = $db->prepare('SELECT * FROM orders WHERE id = ?');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function get_by_stripe_session(string $session_id): ?array {
+ $db = get_db();
+ $stmt = $db->prepare('SELECT * FROM orders WHERE stripe_session_id = ?');
+ $stmt->execute([$session_id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function update(int $id, array $data): void {
+ $db = get_db();
+ $sets = [];
+ $values = [];
+
+ foreach ($data as $key => $value) {
+ $sets[] = "$key = ?";
+ $values[] = $value;
+ }
+
+ $values[] = $id;
+ $sql = 'UPDATE orders SET ' . implode(', ', $sets) . ', updated_at = NOW() WHERE id = ?';
+ $db->prepare($sql)->execute($values);
+ }
+
+ public static function add_item(int $order_id, int $product_id, int $variant_id, int $quantity, int $price): void {
+ $db = get_db();
+ $stmt = $db->prepare('
+ INSERT INTO order_items (order_id, product_id, variant_id, quantity, price)
+ VALUES (?, ?, ?, ?, ?)
+ ');
+ $stmt->execute([$order_id, $product_id, $variant_id, $quantity, $price]);
+ }
+
+ public static function get_items(int $order_id): array {
+ $db = get_db();
+ $stmt = $db->prepare('
+ SELECT oi.*, p.name as product_name, v.size
+ FROM order_items oi
+ JOIN products p ON p.id = oi.product_id
+ JOIN variants v ON v.id = oi.variant_id
+ WHERE oi.order_id = ?
+ ');
+ $stmt->execute([$order_id]);
+ return $stmt->fetchAll();
+ }
+}
diff --git a/app/models/Product_Model.php b/app/models/Product_Model.php
new file mode 100644
index 0000000..13d6b56
--- /dev/null
+++ b/app/models/Product_Model.php
@@ -0,0 +1,55 @@
+<?php
+
+class Product_Model {
+ public static function get_all(): array {
+ $db = get_db();
+ $stmt = $db->query('
+ SELECT id, slug, name, description, price, image_url
+ FROM products
+ WHERE active = true
+ ORDER BY created_at DESC
+ ');
+ return $stmt->fetchAll();
+ }
+
+ public static function get_by_slug(string $slug): ?array {
+ $db = get_db();
+ $stmt = $db->prepare('SELECT * FROM products WHERE slug = ? AND active = true');
+ $stmt->execute([$slug]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function get_by_id(int $id): ?array {
+ $db = get_db();
+ $stmt = $db->prepare('SELECT * FROM products WHERE id = ?');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function get_variants(int $product_id): array {
+ $db = get_db();
+ $stmt = $db->prepare('
+ SELECT id, size, printful_variant_id, stock
+ FROM variants
+ WHERE product_id = ?
+ ORDER BY sort_order
+ ');
+ $stmt->execute([$product_id]);
+ return $stmt->fetchAll();
+ }
+
+ public static function get_variant(int $variant_id): ?array {
+ $db = get_db();
+ $stmt = $db->prepare('
+ SELECT v.*, p.name as product_name, p.price, p.image_url
+ FROM variants v
+ JOIN products p ON p.id = v.product_id
+ WHERE v.id = ?
+ ');
+ $stmt->execute([$variant_id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+}
diff --git a/app/views/cart.php b/app/views/cart.php
new file mode 100644
index 0000000..c1581eb
--- /dev/null
+++ b/app/views/cart.php
@@ -0,0 +1,15 @@
+<?php $title = 'Cart'; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<h1>Your Cart</h1>
+
+<?php require APP_ROOT . '/app/views/partials/cart_items.php'; ?>
+
+<?php if (!empty($items)): ?>
+<div class="cart-actions">
+ <a href="/" class="btn-secondary">Continue Shopping</a>
+ <a href="/checkout" class="btn-primary">Checkout</a>
+</div>
+<?php endif; ?>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/checkout.php b/app/views/checkout.php
new file mode 100644
index 0000000..b757325
--- /dev/null
+++ b/app/views/checkout.php
@@ -0,0 +1,13 @@
+<?php $title = 'Checkout'; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<h1>Checkout</h1>
+
+<p>You'll be redirected to Stripe to complete your purchase securely.</p>
+
+<form method="POST" action="/checkout/create">
+ <?= csrf_field() ?>
+ <button type="submit" class="btn-primary">Proceed to Payment</button>
+</form>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/error.php b/app/views/error.php
new file mode 100644
index 0000000..cf4c204
--- /dev/null
+++ b/app/views/error.php
@@ -0,0 +1,9 @@
+<?php $title = 'Error'; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<div class="error-page">
+ <h1><?= h($error) ?></h1>
+ <a href="/">Back to Home</a>
+</div>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/home.php b/app/views/home.php
new file mode 100644
index 0000000..3e69693
--- /dev/null
+++ b/app/views/home.php
@@ -0,0 +1,22 @@
+<?php $title = 'Home'; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<h1>Merch</h1>
+
+<div class="product-grid">
+ <?php foreach ($products as $p): ?>
+ <article class="product-card">
+ <a href="/product/<?= h($p['slug']) ?>">
+ <img src="<?= h($p['image_url']) ?>" alt="<?= h($p['name']) ?>">
+ <h2><?= h($p['name']) ?></h2>
+ <p class="price"><?= price($p['price']) ?></p>
+ </a>
+ </article>
+ <?php endforeach; ?>
+</div>
+
+<?php if (empty($products)): ?>
+<p>No products yet. Check back soon!</p>
+<?php endif; ?>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/partials/cart_items.php b/app/views/partials/cart_items.php
new file mode 100644
index 0000000..8fa717c
--- /dev/null
+++ b/app/views/partials/cart_items.php
@@ -0,0 +1,53 @@
+<div id="cart-items">
+<?php if (empty($items)): ?>
+ <p class="empty-cart">Your cart is empty.</p>
+<?php else: ?>
+ <table class="cart-table">
+ <thead>
+ <tr>
+ <th>Product</th>
+ <th>Size</th>
+ <th>Price</th>
+ <th>Qty</th>
+ <th>Subtotal</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($items as $item): ?>
+ <tr>
+ <td>
+ <img src="<?= h($item['image_url']) ?>" alt="" class="cart-thumb">
+ <?= h($item['name']) ?>
+ </td>
+ <td><?= h($item['size']) ?></td>
+ <td><?= price($item['price']) ?></td>
+ <td>
+ <form hx-post="/cart/update" hx-target="#cart-items" hx-swap="outerHTML">
+ <input type="hidden" name="product_id" value="<?= $item['product_id'] ?>">
+ <input type="hidden" name="variant_id" value="<?= $item['variant_id'] ?>">
+ <input type="number" name="quantity" value="<?= $item['quantity'] ?>"
+ min="1" max="10" class="qty-input"
+ hx-trigger="change">
+ </form>
+ </td>
+ <td><?= price($item['subtotal']) ?></td>
+ <td>
+ <form hx-post="/cart/remove" hx-target="#cart-items" hx-swap="outerHTML">
+ <input type="hidden" name="product_id" value="<?= $item['product_id'] ?>">
+ <input type="hidden" name="variant_id" value="<?= $item['variant_id'] ?>">
+ <button type="submit" class="btn-remove">×</button>
+ </form>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ <tfoot>
+ <tr>
+ <td colspan="4"><strong>Total</strong></td>
+ <td colspan="2"><strong><?= price(Cart_Model::get_total($items)) ?></strong></td>
+ </tr>
+ </tfoot>
+ </table>
+<?php endif; ?>
+</div>
diff --git a/app/views/partials/cart_widget.php b/app/views/partials/cart_widget.php
new file mode 100644
index 0000000..beaf4c0
--- /dev/null
+++ b/app/views/partials/cart_widget.php
@@ -0,0 +1,4 @@
+<?php $cart_count = Cart_Model::count(); ?>
+<a href="/cart" class="cart-link">
+ Cart<?php if ($cart_count > 0): ?> (<?= $cart_count ?>)<?php endif; ?>
+</a>
diff --git a/app/views/partials/footer.php b/app/views/partials/footer.php
new file mode 100644
index 0000000..7fbee14
--- /dev/null
+++ b/app/views/partials/footer.php
@@ -0,0 +1,6 @@
+</main>
+<footer>
+ <p>© <?= date('Y') ?> <?= h(SITE_NAME) ?></p>
+</footer>
+</body>
+</html>
diff --git a/app/views/partials/header.php b/app/views/partials/header.php
new file mode 100644
index 0000000..83aca2d
--- /dev/null
+++ b/app/views/partials/header.php
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title><?= h($title ?? SITE_NAME) ?></title>
+ <link rel="stylesheet" href="/css/style.css">
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
+ <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+</head>
+<body>
+<header>
+ <nav>
+ <a href="/" class="logo"><?= h(SITE_NAME) ?></a>
+ <div id="cart-widget">
+ <?php require APP_ROOT . '/app/views/partials/cart_widget.php'; ?>
+ </div>
+ </nav>
+</header>
+<main>
diff --git a/app/views/product.php b/app/views/product.php
new file mode 100644
index 0000000..cedf987
--- /dev/null
+++ b/app/views/product.php
@@ -0,0 +1,44 @@
+<?php $title = $product['name']; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<article class="product-detail" x-data="{ selectedSize: '<?= h($variants[0]['id'] ?? '') ?>' }">
+ <div class="product-image">
+ <img src="<?= h($product['image_url']) ?>" alt="<?= h($product['name']) ?>">
+ </div>
+
+ <div class="product-info">
+ <h1><?= h($product['name']) ?></h1>
+ <p class="price"><?= price($product['price']) ?></p>
+ <p class="description"><?= h($product['description']) ?></p>
+
+ <form method="POST" action="/cart/add"
+ hx-post="/cart/add"
+ hx-target="#cart-widget"
+ hx-swap="innerHTML">
+ <input type="hidden" name="product_id" value="<?= $product['id'] ?>">
+
+ <div class="size-selector">
+ <label>Size</label>
+ <div class="sizes">
+ <?php foreach ($variants as $v): ?>
+ <label class="size-option">
+ <input type="radio" name="variant_id" value="<?= $v['id'] ?>"
+ x-model="selectedSize"
+ <?= $v === $variants[0] ? 'checked' : '' ?>>
+ <span><?= h($v['size']) ?></span>
+ </label>
+ <?php endforeach; ?>
+ </div>
+ </div>
+
+ <div class="quantity">
+ <label for="quantity">Quantity</label>
+ <input type="number" name="quantity" id="quantity" value="1" min="1" max="10">
+ </div>
+
+ <button type="submit" class="btn-add">Add to Cart</button>
+ </form>
+ </div>
+</article>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/app/views/success.php b/app/views/success.php
new file mode 100644
index 0000000..666086a
--- /dev/null
+++ b/app/views/success.php
@@ -0,0 +1,11 @@
+<?php $title = 'Order Confirmed'; ?>
+<?php require APP_ROOT . '/app/views/partials/header.php'; ?>
+
+<div class="success-page">
+ <h1>Thanks for your order!</h1>
+ <p>Your order has been placed and will be shipped soon.</p>
+ <p>You'll receive an email confirmation with tracking info.</p>
+ <a href="/" class="btn-primary">Back to Shop</a>
+</div>
+
+<?php require APP_ROOT . '/app/views/partials/footer.php'; ?>
diff --git a/config/autoload.php b/config/autoload.php
new file mode 100644
index 0000000..2f51e2b
--- /dev/null
+++ b/config/autoload.php
@@ -0,0 +1,16 @@
+<?php
+
+spl_autoload_register(function ($class) {
+ $base = dirname(__DIR__);
+ $paths = [
+ $base . '/app/models/',
+ $base . '/app/controllers/',
+ ];
+ foreach ($paths as $path) {
+ $file = $path . $class . '.php';
+ if (file_exists($file)) {
+ require $file;
+ return;
+ }
+ }
+});
diff --git a/config/env.php b/config/env.php
new file mode 100644
index 0000000..de3b245
--- /dev/null
+++ b/config/env.php
@@ -0,0 +1,26 @@
+<?php
+
+define('DB_HOST', getenv('DB_HOST') ?: realpath(__DIR__ . '/../.pgdata'));
+define('DB_NAME', getenv('DB_NAME') ?: 'shop');
+define('DB_USER', getenv('DB_USER') ?: get_current_user());
+define('DB_PASS', getenv('DB_PASS') ?: '');
+
+define('STRIPE_SECRET_KEY', getenv('STRIPE_SECRET_KEY') ?: '');
+define('STRIPE_WEBHOOK_SECRET', getenv('STRIPE_WEBHOOK_SECRET') ?: '');
+
+define('PRINTFUL_API_KEY', getenv('PRINTFUL_API_KEY') ?: '');
+
+define('SITE_URL', getenv('SITE_URL') ?: 'http://localhost:8000');
+define('SITE_NAME', 'TonyBTW Shop');
+
+function get_db(): PDO {
+ static $pdo = null;
+ if ($pdo === null) {
+ $dsn = sprintf('pgsql:host=%s;dbname=%s', DB_HOST, DB_NAME);
+ $pdo = new PDO($dsn, DB_USER, DB_PASS, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+ }
+ return $pdo;
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..e5578c1
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1770562336,
+ "narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..3408af1
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,38 @@
+{
+ description = "shop.tonybtw.com - Merch store";
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ };
+ outputs = {
+ self,
+ nixpkgs,
+ }: let
+ systems = ["x86_64-linux" "aarch64-linux"];
+
+ forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
+ in {
+ devShells = forAllSystems (pkgs: {
+ default = pkgs.mkShell {
+ packages = [
+ pkgs.php
+ pkgs.postgresql
+ pkgs.just
+ ];
+ shellHook = ''
+ export PS1="(shop) $PS1"
+ echo ""
+ echo " shop.tonybtw.com dev"
+ echo " --------------------"
+ echo " just db-start - start local postgres + create db"
+ echo " just db-init - init schema with sample data"
+ echo " just dev - start php server on localhost:8000"
+ echo " just db-shell - connect to database"
+ echo " just db-stop - stop local postgres"
+ echo ""
+ '';
+ };
+ });
+
+ formatter = forAllSystems (pkgs: pkgs.alejandra);
+ };
+}
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..7c3cc78
--- /dev/null
+++ b/justfile
@@ -0,0 +1,27 @@
+pg_dir := ".pgdata"
+
+dev:
+ php -S localhost:8000 -t public
+
+db-start:
+ #!/usr/bin/env bash
+ if [ ! -d "{{pg_dir}}" ]; then
+ initdb -D {{pg_dir}}
+ echo "unix_socket_directories = '$PWD/{{pg_dir}}'" >> {{pg_dir}}/postgresql.conf
+ fi
+ pg_ctl -D {{pg_dir}} -l {{pg_dir}}/log start
+ sleep 1
+ createdb -h $PWD/{{pg_dir}} shop 2>/dev/null || true
+
+db-stop:
+ pg_ctl -D {{pg_dir}} stop
+
+db-init:
+ psql -h $PWD/{{pg_dir}} -d shop -f schema.sql
+
+db-reset:
+ psql -h $PWD/{{pg_dir}} -d shop -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
+ psql -h $PWD/{{pg_dir}} -d shop -f schema.sql
+
+db-shell:
+ psql -h $PWD/{{pg_dir}} -d shop
diff --git a/notes b/notes
new file mode 100644
index 0000000..8fd6c0d
--- /dev/null
+++ b/notes
@@ -0,0 +1,40 @@
+# Next Steps
+
+## Setup
+- [ ] Get Stripe API keys (test mode): https://dashboard.stripe.com/test/apikeys
+- [ ] Get Printful API key: https://www.printful.com/dashboard/settings/api
+- [ ] Set env vars:
+ ```bash
+ export STRIPE_SECRET_KEY="sk_test_..."
+ export STRIPE_WEBHOOK_SECRET="whsec_..."
+ export PRINTFUL_API_KEY="..."
+ ```
+
+## Printful
+- [ ] Create products in Printful dashboard
+- [ ] Sync products to local DB (or build admin to do this)
+- [ ] Map `printful_variant_id` in variants table
+
+## Stripe
+- [ ] Test checkout flow with Stripe test cards
+- [ ] Set up webhook endpoint in Stripe dashboard → `https://shop.tonybtw.com/webhook/stripe`
+- [ ] Handle `checkout.session.completed` event (already stubbed)
+
+## Frontend
+- [ ] Add product images (upload or link to Printful CDN)
+- [ ] Quantity +/- buttons with Alpine
+- [ ] Mobile nav toggle
+- [ ] Loading states for HTMX requests
+
+## Polish
+- [ ] Email confirmation (Stripe receipts or custom)
+- [ ] Order history page (optional)
+- [ ] Meta tags / SEO
+- [ ] Favicon
+
+## Deploy
+- [ ] nginx config for shop.tonybtw.com
+- [ ] SSL cert (certbot)
+- [ ] Point DNS
+- [ ] Set production env vars
+- [ ] Switch Stripe to live keys
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..cc0b3d0
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,360 @@
+:root {
+ --bg: #0d1117;
+ --bg-secondary: #161b22;
+ --text: #e6edf3;
+ --text-muted: #8b949e;
+ --accent: #58a6ff;
+ --accent-hover: #79b8ff;
+ --border: #30363d;
+ --success: #3fb950;
+ --error: #f85149;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.6;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+}
+
+header {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ padding: 1rem 2rem;
+}
+
+header nav {
+ max-width: 1200px;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.logo {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.logo:hover {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.cart-link {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+
+.cart-link:hover {
+ border-color: var(--accent);
+ text-decoration: none;
+}
+
+main {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+ flex: 1;
+ width: 100%;
+}
+
+h1 {
+ margin-bottom: 1.5rem;
+}
+
+/* Product Grid */
+.product-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 1.5rem;
+}
+
+.product-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+ transition: border-color 0.2s;
+}
+
+.product-card:hover {
+ border-color: var(--accent);
+}
+
+.product-card a {
+ display: block;
+ color: var(--text);
+}
+
+.product-card a:hover {
+ text-decoration: none;
+}
+
+.product-card img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ background: var(--bg);
+}
+
+.product-card h2 {
+ font-size: 1rem;
+ padding: 1rem 1rem 0.25rem;
+}
+
+.product-card .price {
+ padding: 0 1rem 1rem;
+ color: var(--accent);
+ font-weight: 600;
+}
+
+/* Product Detail */
+.product-detail {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2rem;
+}
+
+@media (max-width: 768px) {
+ .product-detail {
+ grid-template-columns: 1fr;
+ }
+}
+
+.product-image img {
+ width: 100%;
+ border-radius: 8px;
+ background: var(--bg-secondary);
+}
+
+.product-info h1 {
+ margin-bottom: 0.5rem;
+}
+
+.product-info .price {
+ font-size: 1.5rem;
+ color: var(--accent);
+ font-weight: 600;
+ margin-bottom: 1rem;
+}
+
+.product-info .description {
+ color: var(--text-muted);
+ margin-bottom: 1.5rem;
+}
+
+/* Size Selector */
+.size-selector {
+ margin-bottom: 1rem;
+}
+
+.size-selector label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+}
+
+.sizes {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.size-option {
+ cursor: pointer;
+}
+
+.size-option input {
+ display: none;
+}
+
+.size-option span {
+ display: block;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+
+.size-option input:checked + span {
+ border-color: var(--accent);
+ background: var(--accent);
+ color: var(--bg);
+}
+
+.size-option:hover span {
+ border-color: var(--accent);
+}
+
+/* Quantity */
+.quantity {
+ margin-bottom: 1.5rem;
+}
+
+.quantity label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+}
+
+.quantity input,
+.qty-input {
+ width: 80px;
+ padding: 0.5rem;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 1rem;
+}
+
+/* Buttons */
+.btn-add,
+.btn-primary {
+ background: var(--accent);
+ color: var(--bg);
+ border: none;
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+ font-weight: 600;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-add:hover,
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-secondary {
+ display: inline-block;
+ padding: 0.75rem 1.5rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-weight: 500;
+}
+
+.btn-secondary:hover {
+ border-color: var(--accent);
+ text-decoration: none;
+}
+
+.btn-remove {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.btn-remove:hover {
+ border-color: var(--error);
+ color: var(--error);
+}
+
+/* Cart */
+.cart-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 1.5rem;
+}
+
+.cart-table th,
+.cart-table td {
+ padding: 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+.cart-table th {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.cart-thumb {
+ width: 50px;
+ height: 50px;
+ object-fit: cover;
+ border-radius: 4px;
+ vertical-align: middle;
+ margin-right: 0.75rem;
+}
+
+.cart-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+}
+
+.empty-cart {
+ color: var(--text-muted);
+ text-align: center;
+ padding: 3rem;
+}
+
+/* Success Page */
+.success-page {
+ text-align: center;
+ padding: 3rem;
+}
+
+.success-page h1 {
+ color: var(--success);
+}
+
+.success-page p {
+ color: var(--text-muted);
+ margin-bottom: 0.5rem;
+}
+
+.success-page .btn-primary {
+ margin-top: 1.5rem;
+}
+
+/* Error Page */
+.error-page {
+ text-align: center;
+ padding: 3rem;
+}
+
+.error-page h1 {
+ color: var(--error);
+ margin-bottom: 1rem;
+}
+
+/* Footer */
+footer {
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ padding: 1.5rem 2rem;
+ text-align: center;
+ color: var(--text-muted);
+}
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..e61e265
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,54 @@
+<?php
+
+define('APP_ROOT', dirname(__DIR__));
+
+require APP_ROOT . '/config/env.php';
+require APP_ROOT . '/config/autoload.php';
+require APP_ROOT . '/app/lib/helpers.php';
+require APP_ROOT . '/app/lib/stripe.php';
+require APP_ROOT . '/app/lib/printful.php';
+
+session_start();
+
+$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$uri = rawurldecode($uri);
+$method = $_SERVER['REQUEST_METHOD'];
+
+$routes = [
+ 'GET /' => [Shop_Controller::class, 'index'],
+ 'GET /product/(?<slug>[^/]+)$' => [Shop_Controller::class, 'product'],
+ 'GET /cart$' => [Cart_Controller::class, 'index'],
+ 'POST /cart/add$' => [Cart_Controller::class, 'add'],
+ 'POST /cart/update$' => [Cart_Controller::class, 'update'],
+ 'POST /cart/remove$' => [Cart_Controller::class, 'remove'],
+ 'GET /checkout$' => [Checkout_Controller::class, 'index'],
+ 'POST /checkout/create$' => [Checkout_Controller::class, 'create'],
+ 'GET /success$' => [Checkout_Controller::class, 'success'],
+ 'POST /webhook/stripe$' => [Webhook_Controller::class, 'stripe'],
+];
+
+$handler = null;
+$params = [];
+
+foreach ($routes as $pattern => $h) {
+ [$route_method, $route_pattern] = explode(' ', $pattern, 2);
+ if ($method !== $route_method) continue;
+
+ $regex = '#^' . $route_pattern . '$#';
+ if (preg_match($regex, $uri, $matches)) {
+ $handler = $h;
+ $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
+ break;
+ }
+}
+
+if (!$handler) {
+ http_response_code(404);
+ $error = '404 Not Found';
+ require APP_ROOT . '/app/views/error.php';
+ exit;
+}
+
+[$controller_class, $action] = $handler;
+$controller = new $controller_class();
+$controller->$action($params);
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..466bddd
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,64 @@
+-- Shared schema for both PHP and Go versions
+
+CREATE TABLE IF NOT EXISTS products (
+ id SERIAL PRIMARY KEY,
+ slug VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ price INTEGER NOT NULL, -- cents
+ image_url VARCHAR(500),
+ active BOOLEAN DEFAULT true,
+ printful_product_id VARCHAR(100),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS variants (
+ id SERIAL PRIMARY KEY,
+ product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
+ size VARCHAR(50) NOT NULL,
+ printful_variant_id VARCHAR(100),
+ stock INTEGER DEFAULT 0,
+ sort_order INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS orders (
+ id SERIAL PRIMARY KEY,
+ stripe_session_id VARCHAR(255) UNIQUE,
+ stripe_payment_intent VARCHAR(255),
+ email VARCHAR(255),
+ status VARCHAR(50) DEFAULT 'pending', -- pending, paid, fulfilled, shipped
+ total INTEGER NOT NULL, -- cents
+ shipping_name VARCHAR(255),
+ shipping_address TEXT,
+ printful_order_id VARCHAR(100),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS order_items (
+ id SERIAL PRIMARY KEY,
+ order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
+ product_id INTEGER REFERENCES products(id),
+ variant_id INTEGER REFERENCES variants(id),
+ quantity INTEGER NOT NULL,
+ price INTEGER NOT NULL -- cents at time of purchase
+);
+
+-- Sample data for testing
+INSERT INTO products (slug, name, description, price, image_url) VALUES
+ ('linux-tux-tee', 'Linux Tux Tee', 'Classic penguin on a comfy tee. 100% cotton.', 2500, '/static/img/tux-tee.jpg'),
+ ('btw-i-use-arch-hoodie', 'BTW I Use Arch Hoodie', 'Let everyone know. Heavyweight fleece.', 4500, '/static/img/arch-hoodie.jpg'),
+ ('foss-freedom-cap', 'FOSS Freedom Cap', 'Embroidered dad hat for free software enjoyers.', 2000, '/static/img/foss-cap.jpg')
+ON CONFLICT (slug) DO NOTHING;
+
+INSERT INTO variants (product_id, size, sort_order) VALUES
+ (1, 'S', 1), (1, 'M', 2), (1, 'L', 3), (1, 'XL', 4),
+ (2, 'S', 1), (2, 'M', 2), (2, 'L', 3), (2, 'XL', 4), (2, 'XXL', 5),
+ (3, 'One Size', 1)
+ON CONFLICT DO NOTHING;
+
+CREATE INDEX IF NOT EXISTS idx_products_slug ON products(slug);
+CREATE INDEX IF NOT EXISTS idx_products_active ON products(active);
+CREATE INDEX IF NOT EXISTS idx_variants_product ON variants(product_id);
+CREATE INDEX IF NOT EXISTS idx_orders_stripe ON orders(stripe_session_id);