shop.tonybtw.com

shop.tonybtw.com

https://git.tonybtw.com/shop.tonybtw.com.git git://git.tonybtw.com/shop.tonybtw.com.git

initial commit

Commit
4b4b5060e6b654cfb87511c2c32d18f8a9b469a6
Author
tonybanters <tonybanters@gmail.com>
Date
2026-02-09 08:48:29

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">&times;</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>&copy; <?= 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);