shop.tonybtw.com

shop.tonybtw.com

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

Pivot to go.

Commit
295c7fbc962fea542a74aaa05fcd3d2472865a0e
Parent
4b4b506
Author
tonybanters <tonybanters@gmail.com>
Date
2026-02-09 23:41:57

Diff

diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..664d692
--- /dev/null
+++ b/.air.toml
@@ -0,0 +1,35 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+  bin = "./tmp/shop"
+  cmd = "templ generate && go build -o ./tmp/shop ./cmd/server"
+  delay = 1000
+  exclude_dir = ["public", "tmp", ".pgdata", ".git"]
+  exclude_file = []
+  exclude_regex = ["_test.go", "_templ.go"]
+  exclude_unchanged = false
+  follow_symlink = false
+  full_bin = "./tmp/shop"
+  include_dir = []
+  include_ext = ["go", "templ"]
+  kill_delay = "0s"
+  log = "build-errors.log"
+  send_interrupt = false
+  stop_on_error = true
+
+[color]
+  app = ""
+  build = "yellow"
+  main = "magenta"
+  runner = "green"
+  watcher = "cyan"
+
+[log]
+  time = false
+
+[misc]
+  clean_on_exit = true
+
+[screen]
+  clear_on_rebuild = true
diff --git a/.gitignore b/.gitignore
index b07ab7c..2daa988 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,10 @@
 .env
 *.log
 notes/
+*_templ.go
+shop
+tmp/
+build-errors.log
+shop.db
+shop.db-shm
+shop.db-wal
diff --git a/app/controllers/Cart_Controller.php b/app/controllers/Cart_Controller.php
deleted file mode 100644
index ed5d61e..0000000
--- a/app/controllers/Cart_Controller.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?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
deleted file mode 100644
index a2bb06f..0000000
--- a/app/controllers/Checkout_Controller.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?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
deleted file mode 100644
index 3556534..0000000
--- a/app/controllers/Shop_Controller.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?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
deleted file mode 100644
index 64557b6..0000000
--- a/app/controllers/Webhook_Controller.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?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
deleted file mode 100644
index 5da600b..0000000
--- a/app/lib/helpers.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?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
deleted file mode 100644
index b2bfc1f..0000000
--- a/app/lib/printful.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?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
deleted file mode 100644
index 1d4281f..0000000
--- a/app/lib/stripe.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?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
deleted file mode 100644
index 8f99a31..0000000
--- a/app/models/Cart_Model.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?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
deleted file mode 100644
index c478c3c..0000000
--- a/app/models/Order_Model.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?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
deleted file mode 100644
index 13d6b56..0000000
--- a/app/models/Product_Model.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?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
deleted file mode 100644
index c1581eb..0000000
--- a/app/views/cart.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<?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
deleted file mode 100644
index b757325..0000000
--- a/app/views/checkout.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<?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
deleted file mode 100644
index cf4c204..0000000
--- a/app/views/error.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?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
deleted file mode 100644
index 3e69693..0000000
--- a/app/views/home.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?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
deleted file mode 100644
index 8fa717c..0000000
--- a/app/views/partials/cart_items.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<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
deleted file mode 100644
index beaf4c0..0000000
--- a/app/views/partials/cart_widget.php
+++ /dev/null
@@ -1,4 +0,0 @@
-<?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
deleted file mode 100644
index 7fbee14..0000000
--- a/app/views/partials/footer.php
+++ /dev/null
@@ -1,6 +0,0 @@
-</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
deleted file mode 100644
index 83aca2d..0000000
--- a/app/views/partials/header.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<!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
deleted file mode 100644
index cedf987..0000000
--- a/app/views/product.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?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
deleted file mode 100644
index 666086a..0000000
--- a/app/views/success.php
+++ /dev/null
@@ -1,11 +0,0 @@
-<?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/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..bd9f5d0
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"regexp"
+
+	"shop.tonybtw.com/internal/handlers"
+	"shop.tonybtw.com/internal/lib"
+)
+
+type Route struct {
+	method  string
+	pattern *regexp.Regexp
+	handler http.HandlerFunc
+}
+
+func main() {
+	db, err := lib.Connect_DB()
+	if err != nil {
+		log.Fatal("Failed to connect to database:", err)
+	}
+	defer db.Close()
+
+	ctx := lib.New_App_Context(db)
+
+	shop_handler := handlers.New_Shop_Handler(ctx)
+	cart_handler := handlers.New_Cart_Handler(ctx)
+	checkout_handler := handlers.New_Checkout_Handler(ctx)
+	webhook_handler := handlers.New_Webhook_Handler(ctx)
+
+	routes := []Route{
+		{
+			method:  "GET",
+			pattern: regexp.MustCompile(`^/$`),
+			handler: shop_handler.Show_Home,
+		},
+		{
+			method:  "GET",
+			pattern: regexp.MustCompile(`^/product/([^/]+)$`),
+			handler: func(w http.ResponseWriter, r *http.Request) {
+				matches := regexp.MustCompile(`^/product/([^/]+)$`).FindStringSubmatch(r.URL.Path)
+				if len(matches) > 1 {
+					shop_handler.Show_Product(w, r, matches[1])
+				}
+			},
+		},
+		{
+			method:  "GET",
+			pattern: regexp.MustCompile(`^/cart$`),
+			handler: cart_handler.Show_Cart,
+		},
+		{
+			method:  "POST",
+			pattern: regexp.MustCompile(`^/cart/add$`),
+			handler: cart_handler.Add_Item,
+		},
+		{
+			method:  "POST",
+			pattern: regexp.MustCompile(`^/cart/update$`),
+			handler: cart_handler.Update_Item,
+		},
+		{
+			method:  "POST",
+			pattern: regexp.MustCompile(`^/cart/remove$`),
+			handler: cart_handler.Remove_Item,
+		},
+		{
+			method:  "GET",
+			pattern: regexp.MustCompile(`^/checkout$`),
+			handler: checkout_handler.Show_Checkout,
+		},
+		{
+			method:  "POST",
+			pattern: regexp.MustCompile(`^/checkout/create$`),
+			handler: lib.Require_CSRF(checkout_handler.Create_Session),
+		},
+		{
+			method:  "GET",
+			pattern: regexp.MustCompile(`^/success$`),
+			handler: checkout_handler.Show_Success,
+		},
+		{
+			method:  "POST",
+			pattern: regexp.MustCompile(`^/webhook/stripe$`),
+			handler: webhook_handler.Handle_Stripe,
+		},
+	}
+
+	http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public/static"))))
+
+	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		for _, route := range routes {
+			if r.Method == route.method && route.pattern.MatchString(r.URL.Path) {
+				route.handler(w, r)
+				return
+			}
+		}
+
+		http.NotFound(w, r)
+	})
+
+	port := os.Getenv("PORT")
+	if port == "" {
+		port = "8080"
+	}
+
+	fmt.Printf("Server starting on http://localhost:%s\n", port)
+	if err := http.ListenAndServe(":"+port, nil); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/config/autoload.php b/config/autoload.php
deleted file mode 100644
index 2f51e2b..0000000
--- a/config/autoload.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<?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
deleted file mode 100644
index de3b245..0000000
--- a/config/env.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?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.nix b/flake.nix
index 3408af1..659ee85 100644
--- a/flake.nix
+++ b/flake.nix
@@ -14,20 +14,21 @@
     devShells = forAllSystems (pkgs: {
       default = pkgs.mkShell {
         packages = [
-          pkgs.php
-          pkgs.postgresql
+          pkgs.go
+          pkgs.templ
+          pkgs.air
+          pkgs.mprocs
+          pkgs.sqlite
           pkgs.just
         ];
         shellHook = ''
           export PS1="(shop) $PS1"
+          export PATH=$PATH:~/go/bin
           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 "  shop.tonybtw.com"
+          echo "  ----------------"
+          echo "  mprocs       - dev server with hot reload"
+          echo "  just db-init - setup database"
           echo ""
         '';
       };
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a95f354
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module shop.tonybtw.com
+
+go 1.23.0
+
+require (
+	github.com/a-h/templ v0.3.977
+	github.com/stripe/stripe-go/v78 v78.12.0
+	modernc.org/sqlite v1.29.1
+)
+
+require (
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/google/uuid v1.3.0 // indirect
+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/ncruces/go-strftime v0.1.9 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	golang.org/x/sys v0.34.0 // indirect
+	modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+	modernc.org/libc v1.41.0 // indirect
+	modernc.org/mathutil v1.6.0 // indirect
+	modernc.org/memory v1.7.2 // indirect
+	modernc.org/strutil v1.2.0 // indirect
+	modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..cb3dfc4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,67 @@
+github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
+github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stripe/stripe-go/v78 v78.12.0 h1:YzKjO5Cx1dTfSkqBXzg6GFG7LnRHkZiU0+k0vSF5yt4=
+github.com/stripe/stripe-go/v78 v78.12.0/go.mod h1:GjncxVLUc1xoIOidFqVwq+y3pYiG7JLVWiVQxTsLrvQ=
+golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
+golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
+golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
+golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
+modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
+modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
+modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA=
+modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/handlers/cart.go b/internal/handlers/cart.go
new file mode 100644
index 0000000..74cd5b6
--- /dev/null
+++ b/internal/handlers/cart.go
@@ -0,0 +1,141 @@
+package handlers
+
+import (
+	"net/http"
+	"strconv"
+
+	"shop.tonybtw.com/internal/lib"
+	"shop.tonybtw.com/internal/models"
+	"shop.tonybtw.com/internal/views"
+)
+
+type Cart_Handler struct {
+	ctx *lib.App_Context
+}
+
+func New_Cart_Handler(ctx *lib.App_Context) *Cart_Handler {
+	return &Cart_Handler{ctx: ctx}
+}
+
+func (h *Cart_Handler) Show_Cart(w http.ResponseWriter, r *http.Request) {
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+
+	items, err := models.Get_Cart_Items_With_Details(h.ctx.DB, cart)
+	if err != nil {
+		http.Error(w, "Failed to load cart", http.StatusInternalServerError)
+		return
+	}
+
+	total := models.Get_Cart_Total(items)
+	cart_count := models.Count_Cart_Items(cart)
+
+	views.Cart(items, total, cart_count).Render(r.Context(), w)
+}
+
+func (h *Cart_Handler) Add_Item(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid form", http.StatusBadRequest)
+		return
+	}
+
+	product_id, _ := strconv.Atoi(r.FormValue("product_id"))
+	variant_id, _ := strconv.Atoi(r.FormValue("variant_id"))
+	quantity, _ := strconv.Atoi(r.FormValue("quantity"))
+
+	if quantity < 1 {
+		quantity = 1
+	}
+
+	session_id := lib.Get_Or_Create_Session_ID(w, r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+
+	cart = models.Add_To_Cart(cart, product_id, variant_id, quantity)
+
+	updated_cart_json, _ := models.Serialize_Cart(cart)
+	h.ctx.Session_Store.Set_Cart(session_id, updated_cart_json)
+
+	cart_count := models.Count_Cart_Items(cart)
+
+	if lib.Is_HTMX_Request(r) {
+		views.Cart_Widget(cart_count).Render(r.Context(), w)
+		return
+	}
+
+	referer := r.Header.Get("Referer")
+	if referer == "" {
+		referer = "/"
+	}
+	lib.Redirect(w, r, referer)
+}
+
+func (h *Cart_Handler) Update_Item(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid form", http.StatusBadRequest)
+		return
+	}
+
+	product_id, _ := strconv.Atoi(r.FormValue("product_id"))
+	variant_id, _ := strconv.Atoi(r.FormValue("variant_id"))
+	quantity, _ := strconv.Atoi(r.FormValue("quantity"))
+
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+
+	if quantity == 0 {
+		cart = models.Remove_From_Cart(cart, product_id, variant_id)
+	} else {
+		cart = models.Update_Cart_Quantity(cart, product_id, variant_id, quantity)
+	}
+
+	updated_cart_json, _ := models.Serialize_Cart(cart)
+	h.ctx.Session_Store.Set_Cart(session_id, updated_cart_json)
+
+	items, err := models.Get_Cart_Items_With_Details(h.ctx.DB, cart)
+	if err != nil {
+		http.Error(w, "Failed to load cart", http.StatusInternalServerError)
+		return
+	}
+
+	if lib.Is_HTMX_Request(r) {
+		views.Cart_Items(items).Render(r.Context(), w)
+		return
+	}
+
+	lib.Redirect(w, r, "/cart")
+}
+
+func (h *Cart_Handler) Remove_Item(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, "Invalid form", http.StatusBadRequest)
+		return
+	}
+
+	product_id, _ := strconv.Atoi(r.FormValue("product_id"))
+	variant_id, _ := strconv.Atoi(r.FormValue("variant_id"))
+
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+
+	cart = models.Remove_From_Cart(cart, product_id, variant_id)
+
+	updated_cart_json, _ := models.Serialize_Cart(cart)
+	h.ctx.Session_Store.Set_Cart(session_id, updated_cart_json)
+
+	items, err := models.Get_Cart_Items_With_Details(h.ctx.DB, cart)
+	if err != nil {
+		http.Error(w, "Failed to load cart", http.StatusInternalServerError)
+		return
+	}
+
+	if lib.Is_HTMX_Request(r) {
+		views.Cart_Items(items).Render(r.Context(), w)
+		return
+	}
+
+	lib.Redirect(w, r, "/cart")
+}
diff --git a/internal/handlers/checkout.go b/internal/handlers/checkout.go
new file mode 100644
index 0000000..34afda3
--- /dev/null
+++ b/internal/handlers/checkout.go
@@ -0,0 +1,92 @@
+package handlers
+
+import (
+	"fmt"
+	"net/http"
+
+	"shop.tonybtw.com/internal/lib"
+	"shop.tonybtw.com/internal/models"
+	"shop.tonybtw.com/internal/views"
+)
+
+type Checkout_Handler struct {
+	ctx *lib.App_Context
+}
+
+func New_Checkout_Handler(ctx *lib.App_Context) *Checkout_Handler {
+	return &Checkout_Handler{ctx: ctx}
+}
+
+func (h *Checkout_Handler) Show_Checkout(w http.ResponseWriter, r *http.Request) {
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+
+	if len(cart) == 0 {
+		lib.Redirect(w, r, "/cart")
+		return
+	}
+
+	items, err := models.Get_Cart_Items_With_Details(h.ctx.DB, cart)
+	if err != nil {
+		http.Error(w, "Failed to load cart", http.StatusInternalServerError)
+		return
+	}
+
+	total := models.Get_Cart_Total(items)
+	csrf_token := lib.Get_CSRF_Token(session_id)
+	cart_count := models.Count_Cart_Items(cart)
+
+	views.Checkout(items, total, csrf_token, cart_count).Render(r.Context(), w)
+}
+
+func (h *Checkout_Handler) Create_Session(w http.ResponseWriter, r *http.Request) {
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+
+	if len(cart) == 0 {
+		lib.Redirect(w, r, "/cart")
+		return
+	}
+
+	items, err := models.Get_Cart_Items_With_Details(h.ctx.DB, cart)
+	if err != nil {
+		http.Error(w, "Failed to load cart", http.StatusInternalServerError)
+		return
+	}
+
+	var line_items []lib.Stripe_Line_Item
+	for _, item := range items {
+		line_items = append(line_items, lib.Stripe_Line_Item{
+			Name:     fmt.Sprintf("%s (%s)", item.Name, item.Size),
+			Amount:   int64(item.Price),
+			Quantity: int64(item.Quantity),
+		})
+	}
+
+	host := r.Host
+	scheme := "http"
+	if r.TLS != nil {
+		scheme = "https"
+	}
+
+	success_url := fmt.Sprintf("%s://%s/success", scheme, host)
+	cancel_url := fmt.Sprintf("%s://%s/cart", scheme, host)
+
+	stripe_session, err := lib.Create_Checkout_Session(line_items, success_url, cancel_url)
+	if err != nil {
+		cart_count := models.Count_Cart_Items(cart)
+		views.Error("Failed to create checkout session", cart_count).Render(r.Context(), w)
+		return
+	}
+
+	lib.Redirect(w, r, stripe_session.URL)
+}
+
+func (h *Checkout_Handler) Show_Success(w http.ResponseWriter, r *http.Request) {
+	session_id := lib.Get_Session_ID(r)
+	h.ctx.Session_Store.Set_Cart(session_id, "")
+
+	views.Success(0).Render(r.Context(), w)
+}
diff --git a/internal/handlers/shop.go b/internal/handlers/shop.go
new file mode 100644
index 0000000..088e07b
--- /dev/null
+++ b/internal/handlers/shop.go
@@ -0,0 +1,63 @@
+package handlers
+
+import (
+	"net/http"
+
+	"shop.tonybtw.com/internal/lib"
+	"shop.tonybtw.com/internal/models"
+	"shop.tonybtw.com/internal/views"
+)
+
+type Shop_Handler struct {
+	ctx *lib.App_Context
+}
+
+func New_Shop_Handler(ctx *lib.App_Context) *Shop_Handler {
+	return &Shop_Handler{ctx: ctx}
+}
+
+func (h *Shop_Handler) Show_Home(w http.ResponseWriter, r *http.Request) {
+	products, err := models.Get_All_Products(h.ctx.DB)
+	if err != nil {
+		http.Error(w, "Failed to load products", http.StatusInternalServerError)
+		return
+	}
+
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+	cart_count := models.Count_Cart_Items(cart)
+
+	views.Home(products, cart_count).Render(r.Context(), w)
+}
+
+func (h *Shop_Handler) Show_Product(w http.ResponseWriter, r *http.Request, slug string) {
+	product, err := models.Get_Product_By_Slug(h.ctx.DB, slug)
+	if err != nil {
+		http.Error(w, "Database error", http.StatusInternalServerError)
+		return
+	}
+
+	if product == nil {
+		session_id := lib.Get_Session_ID(r)
+		cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+		cart, _ := models.Parse_Cart(cart_json)
+		cart_count := models.Count_Cart_Items(cart)
+		views.Error("Product not found", cart_count).Render(r.Context(), w)
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+
+	variants, err := models.Get_Product_Variants(h.ctx.DB, product.ID)
+	if err != nil {
+		http.Error(w, "Failed to load variants: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	session_id := lib.Get_Session_ID(r)
+	cart_json := h.ctx.Session_Store.Get_Cart(session_id)
+	cart, _ := models.Parse_Cart(cart_json)
+	cart_count := models.Count_Cart_Items(cart)
+
+	views.Product(*product, variants, cart_count).Render(r.Context(), w)
+}
diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go
new file mode 100644
index 0000000..d1ba064
--- /dev/null
+++ b/internal/handlers/webhook.go
@@ -0,0 +1,86 @@
+package handlers
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+
+	"shop.tonybtw.com/internal/lib"
+	"shop.tonybtw.com/internal/models"
+)
+
+type Webhook_Handler struct {
+	ctx *lib.App_Context
+}
+
+func New_Webhook_Handler(ctx *lib.App_Context) *Webhook_Handler {
+	return &Webhook_Handler{ctx: ctx}
+}
+
+func (h *Webhook_Handler) Handle_Stripe(w http.ResponseWriter, r *http.Request) {
+	payload, err := io.ReadAll(r.Body)
+	if err != nil {
+		http.Error(w, "Invalid payload", http.StatusBadRequest)
+		return
+	}
+
+	sig_header := r.Header.Get("Stripe-Signature")
+
+	event, err := lib.Verify_Webhook(payload, sig_header)
+	if err != nil {
+		http.Error(w, "Invalid signature", http.StatusBadRequest)
+		return
+	}
+
+	event_type, _ := event["type"].(string)
+
+	if event_type == "checkout.session.completed" {
+		data := event["data"].(map[string]interface{})
+		session_obj := data["object"].(map[string]interface{})
+
+		h.handle_checkout_complete(session_obj)
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(map[string]bool{"received": true})
+}
+
+func (h *Webhook_Handler) handle_checkout_complete(session map[string]interface{}) {
+	shipping_details := map[string]interface{}{}
+	if sd, ok := session["shipping_details"].(map[string]interface{}); ok {
+		shipping_details = sd
+	} else if s, ok := session["shipping"].(map[string]interface{}); ok {
+		shipping_details = s
+	}
+
+	shipping_name := ""
+	if name, ok := shipping_details["name"].(string); ok {
+		shipping_name = name
+	}
+
+	shipping_address := ""
+	if addr, ok := shipping_details["address"].(map[string]interface{}); ok {
+		addr_bytes, _ := json.Marshal(addr)
+		shipping_address = string(addr_bytes)
+	}
+
+	customer_details := session["customer_details"].(map[string]interface{})
+	email := customer_details["email"].(string)
+
+	order_id, err := models.Create_Order(
+		h.ctx.DB,
+		session["id"].(string),
+		session["payment_intent"].(string),
+		email,
+		int(session["amount_total"].(float64)),
+		"paid",
+		shipping_name,
+		shipping_address,
+	)
+
+	if err != nil {
+		return
+	}
+
+	_ = order_id
+}
diff --git a/internal/lib/csrf.go b/internal/lib/csrf.go
new file mode 100644
index 0000000..45e29fa
--- /dev/null
+++ b/internal/lib/csrf.go
@@ -0,0 +1,61 @@
+package lib
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+	"net/http"
+	"sync"
+)
+
+var (
+	csrf_tokens = make(map[string]string)
+	csrf_mutex  sync.RWMutex
+)
+
+func Generate_CSRF_Token() string {
+	b := make([]byte, 32)
+	rand.Read(b)
+	return base64.URLEncoding.EncodeToString(b)
+}
+
+func Get_CSRF_Token(session_id string) string {
+	csrf_mutex.RLock()
+	token, exists := csrf_tokens[session_id]
+	csrf_mutex.RUnlock()
+
+	if !exists {
+		token = Generate_CSRF_Token()
+		csrf_mutex.Lock()
+		csrf_tokens[session_id] = token
+		csrf_mutex.Unlock()
+	}
+
+	return token
+}
+
+func Verify_CSRF_Token(session_id, token string) bool {
+	csrf_mutex.RLock()
+	expected := csrf_tokens[session_id]
+	csrf_mutex.RUnlock()
+
+	return token == expected && token != ""
+}
+
+func Require_CSRF(next http.HandlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "POST" {
+			session_id := Get_Session_ID(r)
+			if err := r.ParseForm(); err != nil {
+				http.Error(w, "Invalid form data", http.StatusBadRequest)
+				return
+			}
+
+			token := r.FormValue("csrf_token")
+			if !Verify_CSRF_Token(session_id, token) {
+				http.Error(w, "Invalid CSRF token", http.StatusForbidden)
+				return
+			}
+		}
+		next(w, r)
+	}
+}
diff --git a/internal/lib/database.go b/internal/lib/database.go
new file mode 100644
index 0000000..3abd024
--- /dev/null
+++ b/internal/lib/database.go
@@ -0,0 +1,29 @@
+package lib
+
+import (
+	"database/sql"
+	"os"
+
+	_ "modernc.org/sqlite"
+)
+
+func Connect_DB() (*sql.DB, error) {
+	db_path := os.Getenv("DATABASE_PATH")
+	if db_path == "" {
+		db_path = "shop.db"
+	}
+
+	db, err := sql.Open("sqlite", db_path)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := db.Ping(); err != nil {
+		return nil, err
+	}
+
+	db.Exec("PRAGMA foreign_keys = ON")
+	db.Exec("PRAGMA journal_mode = WAL")
+
+	return db, nil
+}
diff --git a/internal/lib/helpers.go b/internal/lib/helpers.go
new file mode 100644
index 0000000..5dabb56
--- /dev/null
+++ b/internal/lib/helpers.go
@@ -0,0 +1,18 @@
+package lib
+
+import (
+	"html"
+	"net/http"
+)
+
+func HTML_Escape(s string) string {
+	return html.EscapeString(s)
+}
+
+func Is_HTMX_Request(r *http.Request) bool {
+	return r.Header.Get("HX-Request") == "true"
+}
+
+func Redirect(w http.ResponseWriter, r *http.Request, url string) {
+	http.Redirect(w, r, url, http.StatusSeeOther)
+}
diff --git a/internal/lib/printful.go b/internal/lib/printful.go
new file mode 100644
index 0000000..d7b432e
--- /dev/null
+++ b/internal/lib/printful.go
@@ -0,0 +1,79 @@
+package lib
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+)
+
+const printful_api_url = "https://api.printful.com"
+
+type Printful_Order_Item struct {
+	Variant_ID int `json:"variant_id"`
+	Quantity   int `json:"quantity"`
+}
+
+type Printful_Order_Request struct {
+	Recipient struct {
+		Name    string `json:"name"`
+		Address string `json:"address1"`
+		City    string `json:"city"`
+		State   string `json:"state_code"`
+		Country string `json:"country_code"`
+		Zip     string `json:"zip"`
+	} `json:"recipient"`
+	Items []Printful_Order_Item `json:"items"`
+}
+
+type Printful_Order_Response struct {
+	Code   int `json:"code"`
+	Result struct {
+		ID int `json:"id"`
+	} `json:"result"`
+}
+
+func Create_Printful_Order(order_request Printful_Order_Request) (*Printful_Order_Response, error) {
+	api_key := os.Getenv("PRINTFUL_API_KEY")
+	if api_key == "" {
+		return nil, fmt.Errorf("PRINTFUL_API_KEY not set")
+	}
+
+	body, err := json.Marshal(order_request)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest("POST", printful_api_url+"/orders", bytes.NewBuffer(body))
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("Authorization", "Bearer "+api_key)
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	response_body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	var printful_response Printful_Order_Response
+	if err := json.Unmarshal(response_body, &printful_response); err != nil {
+		return nil, err
+	}
+
+	if printful_response.Code != 200 {
+		return nil, fmt.Errorf("printful API error: code %d", printful_response.Code)
+	}
+
+	return &printful_response, nil
+}
diff --git a/internal/lib/session.go b/internal/lib/session.go
new file mode 100644
index 0000000..04231bd
--- /dev/null
+++ b/internal/lib/session.go
@@ -0,0 +1,43 @@
+package lib
+
+import (
+	"crypto/rand"
+	"encoding/base64"
+	"net/http"
+)
+
+const session_cookie_name = "shop_session"
+
+func Get_Session_ID(r *http.Request) string {
+	cookie, err := r.Cookie(session_cookie_name)
+	if err != nil {
+		return ""
+	}
+	return cookie.Value
+}
+
+func Set_Session_ID(w http.ResponseWriter, session_id string) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     session_cookie_name,
+		Value:    session_id,
+		Path:     "/",
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+		MaxAge:   86400 * 7,
+	})
+}
+
+func Generate_Session_ID() string {
+	b := make([]byte, 32)
+	rand.Read(b)
+	return base64.URLEncoding.EncodeToString(b)
+}
+
+func Get_Or_Create_Session_ID(w http.ResponseWriter, r *http.Request) string {
+	session_id := Get_Session_ID(r)
+	if session_id == "" {
+		session_id = Generate_Session_ID()
+		Set_Session_ID(w, session_id)
+	}
+	return session_id
+}
diff --git a/internal/lib/store.go b/internal/lib/store.go
new file mode 100644
index 0000000..3c2e5a7
--- /dev/null
+++ b/internal/lib/store.go
@@ -0,0 +1,41 @@
+package lib
+
+import (
+	"database/sql"
+	"sync"
+)
+
+type Session_Store struct {
+	mu    sync.RWMutex
+	carts map[string]string
+}
+
+func New_Session_Store() *Session_Store {
+	return &Session_Store{
+		carts: make(map[string]string),
+	}
+}
+
+func (s *Session_Store) Get_Cart(session_id string) string {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return s.carts[session_id]
+}
+
+func (s *Session_Store) Set_Cart(session_id, cart_json string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.carts[session_id] = cart_json
+}
+
+type App_Context struct {
+	DB            *sql.DB
+	Session_Store *Session_Store
+}
+
+func New_App_Context(db *sql.DB) *App_Context {
+	return &App_Context{
+		DB:            db,
+		Session_Store: New_Session_Store(),
+	}
+}
diff --git a/internal/lib/stripe.go b/internal/lib/stripe.go
new file mode 100644
index 0000000..33c39b6
--- /dev/null
+++ b/internal/lib/stripe.go
@@ -0,0 +1,75 @@
+package lib
+
+import (
+	"encoding/json"
+	"os"
+
+	"github.com/stripe/stripe-go/v78"
+	"github.com/stripe/stripe-go/v78/checkout/session"
+	"github.com/stripe/stripe-go/v78/webhook"
+)
+
+func init() {
+	stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
+}
+
+type Stripe_Line_Item struct {
+	Name     string
+	Amount   int64
+	Quantity int64
+}
+
+func Create_Checkout_Session(line_items []Stripe_Line_Item, success_url, cancel_url string) (*stripe.CheckoutSession, error) {
+	params := &stripe.CheckoutSessionParams{
+		PaymentMethodTypes: stripe.StringSlice([]string{"card"}),
+		Mode:               stripe.String(string(stripe.CheckoutSessionModePayment)),
+		SuccessURL:         stripe.String(success_url),
+		CancelURL:          stripe.String(cancel_url),
+		ShippingAddressCollection: &stripe.CheckoutSessionShippingAddressCollectionParams{
+			AllowedCountries: stripe.StringSlice([]string{"US"}),
+		},
+	}
+
+	for _, item := range line_items {
+		params.LineItems = append(params.LineItems, &stripe.CheckoutSessionLineItemParams{
+			PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
+				Currency: stripe.String("usd"),
+				ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
+					Name: stripe.String(item.Name),
+				},
+				UnitAmount: stripe.Int64(item.Amount),
+			},
+			Quantity: stripe.Int64(item.Quantity),
+		})
+	}
+
+	return session.New(params)
+}
+
+func Verify_Webhook(payload []byte, sig_header string) (map[string]interface{}, error) {
+	webhook_secret := os.Getenv("STRIPE_WEBHOOK_SECRET")
+	if webhook_secret == "" {
+		var event map[string]interface{}
+		if err := json.Unmarshal(payload, &event); err != nil {
+			return nil, err
+		}
+		return event, nil
+	}
+
+	event, err := webhook.ConstructEvent(payload, sig_header, webhook_secret)
+	if err != nil {
+		return nil, err
+	}
+
+	var result map[string]interface{}
+	if err := json.Unmarshal(event.Data.Raw, &result); err != nil {
+		return nil, err
+	}
+
+	return map[string]interface{}{
+		"type": event.Type,
+		"data": map[string]interface{}{
+			"object": result,
+		},
+	}, nil
+}
diff --git a/internal/models/cart.go b/internal/models/cart.go
new file mode 100644
index 0000000..129f692
--- /dev/null
+++ b/internal/models/cart.go
@@ -0,0 +1,126 @@
+package models
+
+import (
+	"database/sql"
+	"encoding/json"
+	"fmt"
+)
+
+type Cart_Item struct {
+	Product_ID int
+	Variant_ID int
+	Quantity   int
+}
+
+type Cart_Item_Detail struct {
+	Product_ID int
+	Variant_ID int
+	Quantity   int
+	Name       string
+	Size       string
+	Price      int
+	Image_URL  string
+	Subtotal   int
+}
+
+type Cart map[string]Cart_Item
+
+func cart_key(product_id, variant_id int) string {
+	return fmt.Sprintf("%d_%d", product_id, variant_id)
+}
+
+func Parse_Cart(cart_json string) (Cart, error) {
+	if cart_json == "" {
+		return make(Cart), nil
+	}
+
+	var cart Cart
+	if err := json.Unmarshal([]byte(cart_json), &cart); err != nil {
+		return nil, err
+	}
+	return cart, nil
+}
+
+func Serialize_Cart(cart Cart) (string, error) {
+	data, err := json.Marshal(cart)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func Add_To_Cart(cart Cart, product_id, variant_id, quantity int) Cart {
+	key := cart_key(product_id, variant_id)
+	if existing, ok := cart[key]; ok {
+		existing.Quantity += quantity
+		cart[key] = existing
+	} else {
+		cart[key] = Cart_Item{
+			Product_ID: product_id,
+			Variant_ID: variant_id,
+			Quantity:   quantity,
+		}
+	}
+	return cart
+}
+
+func Update_Cart_Quantity(cart Cart, product_id, variant_id, quantity int) Cart {
+	key := cart_key(product_id, variant_id)
+	if _, ok := cart[key]; ok {
+		cart[key] = Cart_Item{
+			Product_ID: product_id,
+			Variant_ID: variant_id,
+			Quantity:   quantity,
+		}
+	}
+	return cart
+}
+
+func Remove_From_Cart(cart Cart, product_id, variant_id int) Cart {
+	key := cart_key(product_id, variant_id)
+	delete(cart, key)
+	return cart
+}
+
+func Count_Cart_Items(cart Cart) int {
+	total := 0
+	for _, item := range cart {
+		total += item.Quantity
+	}
+	return total
+}
+
+func Get_Cart_Items_With_Details(db *sql.DB, cart Cart) ([]Cart_Item_Detail, error) {
+	var items []Cart_Item_Detail
+
+	for _, item := range cart {
+		variant, err := Get_Variant_By_ID(db, item.Variant_ID)
+		if err != nil {
+			return nil, err
+		}
+		if variant == nil {
+			continue
+		}
+
+		items = append(items, Cart_Item_Detail{
+			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, nil
+}
+
+func Get_Cart_Total(items []Cart_Item_Detail) int {
+	total := 0
+	for _, item := range items {
+		total += item.Subtotal
+	}
+	return total
+}
diff --git a/internal/models/order.go b/internal/models/order.go
new file mode 100644
index 0000000..327b462
--- /dev/null
+++ b/internal/models/order.go
@@ -0,0 +1,160 @@
+package models
+
+import (
+	"database/sql"
+	"fmt"
+	"time"
+)
+
+type Order struct {
+	ID                     int
+	Stripe_Session_ID      string
+	Stripe_Payment_Intent  string
+	Email                  string
+	Total                  int
+	Status                 string
+	Shipping_Name          string
+	Shipping_Address       string
+	Printful_Order_ID      string
+	Created_At             time.Time
+	Updated_At             time.Time
+}
+
+type Order_Item struct {
+	ID         int
+	Order_ID   int
+	Product_ID int
+	Variant_ID int
+	Quantity   int
+	Price      int
+}
+
+type Order_Item_Detail struct {
+	Order_Item
+	Product_Name string
+	Size         string
+}
+
+func Create_Order(
+	db *sql.DB,
+	stripe_session_id string,
+	stripe_payment_intent string,
+	email string,
+	total int,
+	status string,
+	shipping_name string,
+	shipping_address string,
+) (int, error) {
+	var order_id int
+	err := db.QueryRow(`
+		INSERT INTO orders (
+			stripe_session_id,
+			stripe_payment_intent,
+			email,
+			total,
+			status,
+			shipping_name,
+			shipping_address
+		)
+		VALUES ($1, $2, $3, $4, $5, $6, $7)
+		RETURNING id
+	`, stripe_session_id, stripe_payment_intent, email, total, status, shipping_name, shipping_address).Scan(&order_id)
+
+	return order_id, err
+}
+
+func Get_Order_By_ID(db *sql.DB, id int) (*Order, error) {
+	var o Order
+	err := db.QueryRow(`
+		SELECT id, stripe_session_id, stripe_payment_intent, email, total, status,
+		       shipping_name, shipping_address, printful_order_id, created_at, updated_at
+		FROM orders
+		WHERE id = $1
+	`, id).Scan(
+		&o.ID, &o.Stripe_Session_ID, &o.Stripe_Payment_Intent, &o.Email, &o.Total, &o.Status,
+		&o.Shipping_Name, &o.Shipping_Address, &o.Printful_Order_ID, &o.Created_At, &o.Updated_At,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &o, nil
+}
+
+func Get_Order_By_Stripe_Session(db *sql.DB, session_id string) (*Order, error) {
+	var o Order
+	err := db.QueryRow(`
+		SELECT id, stripe_session_id, stripe_payment_intent, email, total, status,
+		       shipping_name, shipping_address, printful_order_id, created_at, updated_at
+		FROM orders
+		WHERE stripe_session_id = $1
+	`, session_id).Scan(
+		&o.ID, &o.Stripe_Session_ID, &o.Stripe_Payment_Intent, &o.Email, &o.Total, &o.Status,
+		&o.Shipping_Name, &o.Shipping_Address, &o.Printful_Order_ID, &o.Created_At, &o.Updated_At,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &o, nil
+}
+
+func Update_Order(db *sql.DB, id int, fields map[string]interface{}) error {
+	query := "UPDATE orders SET updated_at = NOW()"
+	args := []interface{}{}
+	argCount := 1
+
+	for key, value := range fields {
+		query += fmt.Sprintf(", %s = $%d", key, argCount)
+		args = append(args, value)
+		argCount++
+	}
+
+	query += fmt.Sprintf(" WHERE id = $%d", argCount)
+	args = append(args, id)
+
+	_, err := db.Exec(query, args...)
+	return err
+}
+
+func Add_Order_Item(db *sql.DB, order_id, product_id, variant_id, quantity, price int) error {
+	_, err := db.Exec(`
+		INSERT INTO order_items (order_id, product_id, variant_id, quantity, price)
+		VALUES ($1, $2, $3, $4, $5)
+	`, order_id, product_id, variant_id, quantity, price)
+	return err
+}
+
+func Get_Order_Items(db *sql.DB, order_id int) ([]Order_Item_Detail, error) {
+	rows, err := db.Query(`
+		SELECT oi.id, oi.order_id, oi.product_id, oi.variant_id, oi.quantity, oi.price,
+		       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 = $1
+	`, order_id)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var items []Order_Item_Detail
+	for rows.Next() {
+		var item Order_Item_Detail
+		if err := rows.Scan(
+			&item.ID, &item.Order_ID, &item.Product_ID, &item.Variant_ID,
+			&item.Quantity, &item.Price, &item.Product_Name, &item.Size,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, item)
+	}
+	return items, rows.Err()
+}
diff --git a/internal/models/product.go b/internal/models/product.go
new file mode 100644
index 0000000..7bf8853
--- /dev/null
+++ b/internal/models/product.go
@@ -0,0 +1,141 @@
+package models
+
+import (
+	"database/sql"
+)
+
+type Product struct {
+	ID          int
+	Slug        string
+	Name        string
+	Description string
+	Price       int
+	Image_URL   string
+	Active      bool
+}
+
+type Variant struct {
+	ID                  int
+	Product_ID          int
+	Size                string
+	Printful_Variant_ID sql.NullString
+	Stock               int
+	Sort_Order          int
+}
+
+type Product_With_Variant struct {
+	Product_ID          int
+	Variant_ID          int
+	Product_Name        string
+	Size                string
+	Price               int
+	Image_URL           string
+	Printful_Variant_ID sql.NullString
+}
+
+func Get_All_Products(db *sql.DB) ([]Product, error) {
+	rows, err := db.Query(`
+		SELECT id, slug, name, description, price, image_url
+		FROM products
+		WHERE active = true
+		ORDER BY created_at DESC
+	`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var products []Product
+	for rows.Next() {
+		var p Product
+		if err := rows.Scan(&p.ID, &p.Slug, &p.Name, &p.Description, &p.Price, &p.Image_URL); err != nil {
+			return nil, err
+		}
+		products = append(products, p)
+	}
+	return products, rows.Err()
+}
+
+func Get_Product_By_Slug(db *sql.DB, slug string) (*Product, error) {
+	var p Product
+	err := db.QueryRow(`
+		SELECT id, slug, name, description, price, image_url, active
+		FROM products
+		WHERE slug = $1 AND active = true
+	`, slug).Scan(&p.ID, &p.Slug, &p.Name, &p.Description, &p.Price, &p.Image_URL, &p.Active)
+
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &p, nil
+}
+
+func Get_Product_By_ID(db *sql.DB, id int) (*Product, error) {
+	var p Product
+	err := db.QueryRow(`
+		SELECT id, slug, name, description, price, image_url, active
+		FROM products
+		WHERE id = $1
+	`, id).Scan(&p.ID, &p.Slug, &p.Name, &p.Description, &p.Price, &p.Image_URL, &p.Active)
+
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &p, nil
+}
+
+func Get_Product_Variants(db *sql.DB, product_id int) ([]Variant, error) {
+	rows, err := db.Query(`
+		SELECT id, size, printful_variant_id, stock
+		FROM variants
+		WHERE product_id = $1
+		ORDER BY sort_order
+	`, product_id)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var variants []Variant
+	for rows.Next() {
+		var v Variant
+		v.Product_ID = product_id
+		if err := rows.Scan(&v.ID, &v.Size, &v.Printful_Variant_ID, &v.Stock); err != nil {
+			return nil, err
+		}
+		variants = append(variants, v)
+	}
+	return variants, rows.Err()
+}
+
+func Get_Variant_By_ID(db *sql.DB, variant_id int) (*Product_With_Variant, error) {
+	var pwv Product_With_Variant
+	err := db.QueryRow(`
+		SELECT v.id, v.product_id, v.size, v.printful_variant_id, p.name, p.price, p.image_url
+		FROM variants v
+		JOIN products p ON p.id = v.product_id
+		WHERE v.id = $1
+	`, variant_id).Scan(
+		&pwv.Variant_ID,
+		&pwv.Product_ID,
+		&pwv.Size,
+		&pwv.Printful_Variant_ID,
+		&pwv.Product_Name,
+		&pwv.Price,
+		&pwv.Image_URL,
+	)
+
+	if err == sql.ErrNoRows {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &pwv, nil
+}
diff --git a/internal/views/cart.templ b/internal/views/cart.templ
new file mode 100644
index 0000000..9311911
--- /dev/null
+++ b/internal/views/cart.templ
@@ -0,0 +1,82 @@
+package views
+
+import (
+	"fmt"
+	"shop.tonybtw.com/internal/models"
+)
+
+templ Cart(items []models.Cart_Item_Detail, total int, cart_count int) {
+	@Layout("Cart") {
+		<h1>Your Cart</h1>
+		@Cart_Items(items)
+		if len(items) > 0 {
+			<div class="cart-actions">
+				<a href="/" class="btn-secondary">Continue Shopping</a>
+				<a href="/checkout" class="btn-primary">Checkout</a>
+			</div>
+		}
+		@Cart_Widget(cart_count)
+	}
+}
+
+templ Cart_Items(items []models.Cart_Item_Detail) {
+	<div id="cart-items">
+		if len(items) == 0 {
+			<p class="empty-cart">Your cart is empty.</p>
+		} 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>
+					for _, item := range items {
+						<tr>
+							<td>
+								<img src={ item.Image_URL } alt="" class="cart-thumb"/>
+								{ item.Name }
+							</td>
+							<td>{ item.Size }</td>
+							<td>{ Format_Price(item.Price) }</td>
+							<td>
+								<form hx-post="/cart/update" hx-target="#cart-items" hx-swap="outerHTML">
+									<input type="hidden" name="product_id" value={ templ.EscapeString(fmt.Sprintf("%d", item.Product_ID)) }/>
+									<input type="hidden" name="variant_id" value={ templ.EscapeString(fmt.Sprintf("%d", item.Variant_ID)) }/>
+									<input
+										type="number"
+										name="quantity"
+										value={ templ.EscapeString(fmt.Sprintf("%d", item.Quantity)) }
+										min="1"
+										max="10"
+										class="qty-input"
+										hx-trigger="change"
+									/>
+								</form>
+							</td>
+							<td>{ Format_Price(item.Subtotal) }</td>
+							<td>
+								<form hx-post="/cart/remove" hx-target="#cart-items" hx-swap="outerHTML">
+									<input type="hidden" name="product_id" value={ templ.EscapeString(fmt.Sprintf("%d", item.Product_ID)) }/>
+									<input type="hidden" name="variant_id" value={ templ.EscapeString(fmt.Sprintf("%d", item.Variant_ID)) }/>
+									<button type="submit" class="btn-remove">&times;</button>
+								</form>
+							</td>
+						</tr>
+					}
+				</tbody>
+				<tfoot>
+					<tr>
+						<td colspan="4"><strong>Total</strong></td>
+						<td colspan="2"><strong>{ Format_Price(models.Get_Cart_Total(items)) }</strong></td>
+					</tr>
+				</tfoot>
+			</table>
+		}
+	</div>
+}
diff --git a/internal/views/cart_widget.templ b/internal/views/cart_widget.templ
new file mode 100644
index 0000000..d3ad0e7
--- /dev/null
+++ b/internal/views/cart_widget.templ
@@ -0,0 +1,14 @@
+package views
+
+import "fmt"
+
+templ Cart_Widget(count int) {
+	<div id="cart-widget">
+		<a href="/cart" class="cart-link">
+			Cart
+			if count > 0 {
+				({ templ.EscapeString(fmt.Sprintf("%d", count)) })
+			}
+		</a>
+	</div>
+}
diff --git a/internal/views/checkout.templ b/internal/views/checkout.templ
new file mode 100644
index 0000000..19adaf7
--- /dev/null
+++ b/internal/views/checkout.templ
@@ -0,0 +1,40 @@
+package views
+
+import (
+	"fmt"
+	"shop.tonybtw.com/internal/models"
+)
+
+templ Checkout(items []models.Cart_Item_Detail, total int, csrf_token string, cart_count int) {
+	@Layout("Checkout") {
+		<h1>Checkout</h1>
+		<div class="checkout-summary">
+			<h2>Order Summary</h2>
+			<table class="cart-table">
+				<tbody>
+					for _, item := range items {
+						<tr>
+							<td>{ item.Name } ({ item.Size })</td>
+							<td>x{ templ.EscapeString(fmt.Sprintf("%d", item.Quantity)) }</td>
+							<td>{ Format_Price(item.Subtotal) }</td>
+						</tr>
+					}
+				</tbody>
+				<tfoot>
+					<tr>
+						<td colspan="2"><strong>Total</strong></td>
+						<td><strong>{ Format_Price(total) }</strong></td>
+					</tr>
+				</tfoot>
+			</table>
+		</div>
+
+		<p>You'll be redirected to Stripe to complete your purchase.</p>
+
+		<form action="/checkout/create" method="POST">
+			<input type="hidden" name="csrf_token" value={ csrf_token }/>
+			<button type="submit" class="btn-primary">Continue to Payment</button>
+		</form>
+		@Cart_Widget(cart_count)
+	}
+}
diff --git a/internal/views/error.templ b/internal/views/error.templ
new file mode 100644
index 0000000..54c6b67
--- /dev/null
+++ b/internal/views/error.templ
@@ -0,0 +1,12 @@
+package views
+
+templ Error(message string, cart_count int) {
+	@Layout("Error") {
+		<div class="error-message">
+			<h1>Error</h1>
+			<p>{ message }</p>
+			<a href="/" class="btn-primary">Go Home</a>
+		</div>
+		@Cart_Widget(cart_count)
+	}
+}
diff --git a/internal/views/helpers.templ b/internal/views/helpers.templ
new file mode 100644
index 0000000..80819a9
--- /dev/null
+++ b/internal/views/helpers.templ
@@ -0,0 +1,11 @@
+package views
+
+import "fmt"
+
+func Format_Price(cents int) string {
+	dollars := float64(cents) / 100.0
+	return fmt.Sprintf("$%.2f", dollars)
+}
+
+templ Dummy() {
+}
diff --git a/internal/views/home.templ b/internal/views/home.templ
new file mode 100644
index 0000000..322da76
--- /dev/null
+++ b/internal/views/home.templ
@@ -0,0 +1,19 @@
+package views
+
+import "shop.tonybtw.com/internal/models"
+
+templ Home(products []models.Product, cart_count int) {
+	@Layout("Shop") {
+		<h1>Products</h1>
+		<div class="product-grid">
+			for _, product := range products {
+				<a href={ templ.URL("/product/" + product.Slug) } class="product-card">
+					<img src={ product.Image_URL } alt={ product.Name }/>
+					<h2>{ product.Name }</h2>
+					<p>{ Format_Price(product.Price) }</p>
+				</a>
+			}
+		</div>
+		@Cart_Widget(cart_count)
+	}
+}
diff --git a/internal/views/layout.templ b/internal/views/layout.templ
new file mode 100644
index 0000000..7d842e7
--- /dev/null
+++ b/internal/views/layout.templ
@@ -0,0 +1,31 @@
+package views
+
+templ Layout(title string) {
+	<!DOCTYPE html>
+	<html lang="en">
+	<head>
+		<meta charset="UTF-8"/>
+		<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+		<title>{ title }</title>
+		<link rel="stylesheet" href="/static/style.css"/>
+		<script src="https://unpkg.com/htmx.org@2.0.0"></script>
+		<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
+	</head>
+	<body>
+		<header>
+			<nav>
+				<a href="/" class="logo">shop.tonybtw.com</a>
+				<div id="cart-widget" hx-swap-oob="true">
+					@Cart_Widget(0)
+				</div>
+			</nav>
+		</header>
+		<main>
+			{ children... }
+		</main>
+		<footer>
+			<p>&copy; 2026 tonybtw. All rights reserved.</p>
+		</footer>
+	</body>
+	</html>
+}
diff --git a/internal/views/product.templ b/internal/views/product.templ
new file mode 100644
index 0000000..1100ddf
--- /dev/null
+++ b/internal/views/product.templ
@@ -0,0 +1,42 @@
+package views
+
+import (
+	"fmt"
+	"shop.tonybtw.com/internal/models"
+)
+
+templ Product(product models.Product, variants []models.Variant, cart_count int) {
+	@Layout(product.Name) {
+		<div class="product-detail">
+			<img src={ product.Image_URL } alt={ product.Name } class="product-image"/>
+			<div class="product-info">
+				<h1>{ product.Name }</h1>
+				<p class="product-price">{ Format_Price(product.Price) }</p>
+				<p class="product-description">{ product.Description }</p>
+
+				<form hx-post="/cart/add" hx-target="#cart-widget" hx-swap="outerHTML">
+					<input type="hidden" name="product_id" value={ templ.EscapeString(fmt.Sprintf("%d", product.ID)) }/>
+
+					<label for="variant_id">Size:</label>
+					<select name="variant_id" id="variant_id" required>
+						for _, variant := range variants {
+							<option value={ templ.EscapeString(fmt.Sprintf("%d", variant.ID)) }>
+								{ variant.Size }
+							</option>
+						}
+					</select>
+
+					<label for="quantity">Quantity:</label>
+					<select name="quantity" id="quantity">
+						for i := 1; i <= 10; i++ {
+							<option value={ templ.EscapeString(fmt.Sprintf("%d", i)) }>{ templ.EscapeString(fmt.Sprintf("%d", i)) }</option>
+						}
+					</select>
+
+					<button type="submit" class="btn-primary">Add to Cart</button>
+				</form>
+			</div>
+		</div>
+		@Cart_Widget(cart_count)
+	}
+}
diff --git a/internal/views/success.templ b/internal/views/success.templ
new file mode 100644
index 0000000..c61087e
--- /dev/null
+++ b/internal/views/success.templ
@@ -0,0 +1,13 @@
+package views
+
+templ Success(cart_count int) {
+	@Layout("Order Complete") {
+		<div class="success-message">
+			<h1>Thank you for your order!</h1>
+			<p>Your order has been received and is being processed.</p>
+			<p>You'll receive a confirmation email shortly.</p>
+			<a href="/" class="btn-primary">Continue Shopping</a>
+		</div>
+		@Cart_Widget(cart_count)
+	}
+}
diff --git a/justfile b/justfile
index 7c3cc78..3bbd1b8 100644
--- a/justfile
+++ b/justfile
@@ -1,27 +1,18 @@
-pg_dir := ".pgdata"
+generate:
+    templ generate
 
-dev:
-    php -S localhost:8000 -t public
+build: generate
+    go build -o shop ./cmd/server
 
-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
+dev: generate build
+    ./shop
 
 db-init:
-    psql -h $PWD/{{pg_dir}} -d shop -f schema.sql
+    sqlite3 shop.db < schema.sqlite.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
+    rm -f shop.db shop.db-shm shop.db-wal
+    sqlite3 shop.db < schema.sqlite.sql
 
 db-shell:
-    psql -h $PWD/{{pg_dir}} -d shop
+    sqlite3 shop.db
diff --git a/mprocs.yaml b/mprocs.yaml
new file mode 100644
index 0000000..3b76c95
--- /dev/null
+++ b/mprocs.yaml
@@ -0,0 +1,4 @@
+procs:
+  server:
+    cmd: ["air"]
+    stop: "SIGTERM"
diff --git a/next-steps.md b/next-steps.md
new file mode 100644
index 0000000..27e8744
--- /dev/null
+++ b/next-steps.md
@@ -0,0 +1,91 @@
+# shop.tonybtw.com - Next Steps
+
+## Quick Start
+
+```bash
+# 1. Enter nix shell
+nix develop
+
+# 2. Initialize database
+just db-init
+
+# 3. Start dev server with hot reload
+mprocs
+```
+
+Visit `http://localhost:8080`
+
+---
+
+## Project Structure
+
+```
+cmd/server/          - Main application entry point
+internal/
+  ├── handlers/      - HTTP handlers (routes)
+  ├── models/        - Database models & queries
+  └── views/         - Templ templates
+public/static/       - CSS, images, etc.
+schema.sqlite.sql    - Database schema
+```
+
+---
+
+## Available Commands
+
+### Development
+- `mprocs` - Start dev server with hot reload (templ + air)
+- `just dev` - Generate + build + run (no hot reload)
+- `just generate` - Generate Go code from templ templates
+- `just build` - Build the binary
+
+### Database
+- `just db-init` - Create database from schema
+- `just db-reset` - Drop and recreate database
+- `just db-shell` - Open SQLite shell
+
+---
+
+## Environment Variables
+
+Create `.env` file:
+
+```bash
+STRIPE_SECRET_KEY=sk_test_...
+STRIPE_PUBLISHABLE_KEY=pk_test_...
+STRIPE_WEBHOOK_SECRET=whsec_...
+PRINTFUL_API_KEY=...
+```
+
+---
+
+## Deploying to Another Machine
+
+1. Clone the repo
+2. Copy `shop.db` manually (optional, or recreate with `just db-init`)
+3. Copy `.env` file
+4. Run `nix develop` and `mprocs`
+
+---
+
+## Tech Stack
+
+- **Go** - Backend
+- **Templ** - Type-safe HTML templating
+- **SQLite** - Database
+- **HTMX** - Frontend interactivity
+- **Stripe** - Payments
+- **Printful** - Print-on-demand fulfillment
+
+---
+
+## TODO
+
+- [ ] Add session management (cookies/JWT)
+- [ ] Implement cart persistence across sessions
+- [ ] Add product image uploads
+- [ ] Set up deployment (fly.io/railway/etc)
+- [ ] Add order confirmation emails
+- [ ] Implement webhook retry logic
+- [ ] Add admin panel for product management
+- [ ] Add tests
diff --git a/notes b/notes
deleted file mode 100644
index 8fd6c0d..0000000
--- a/notes
+++ /dev/null
@@ -1,40 +0,0 @@
-# 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
deleted file mode 100644
index cc0b3d0..0000000
--- a/public/css/style.css
+++ /dev/null
@@ -1,360 +0,0 @@
-: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
deleted file mode 100644
index e61e265..0000000
--- a/public/index.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?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/public/static/style.css b/public/static/style.css
new file mode 100644
index 0000000..799becb
--- /dev/null
+++ b/public/static/style.css
@@ -0,0 +1,476 @@
+:root {
+    --bg: #0a0a0a;
+    --bg-secondary: #111111;
+    --bg-tertiary: #1a1a1a;
+    --text: #ffffff;
+    --text-muted: #888888;
+    --accent: #3b82f6;
+    --accent-hover: #60a5fa;
+    --border: #222222;
+    --success: #10b981;
+    --error: #ef4444;
+}
+
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
+    background: var(--bg);
+    color: var(--text);
+    line-height: 1.6;
+    min-height: 100vh;
+    display: flex;
+    flex-direction: column;
+    font-size: 15px;
+}
+
+a {
+    color: var(--accent);
+    text-decoration: none;
+    transition: color 0.2s;
+}
+
+a:hover {
+    color: var(--accent-hover);
+}
+
+header {
+    background: var(--bg-secondary);
+    border-bottom: 1px solid var(--border);
+    padding: 1.25rem 2rem;
+    backdrop-filter: blur(10px);
+}
+
+header nav {
+    max-width: 1200px;
+    margin: 0 auto;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.logo {
+    font-size: 1.125rem;
+    font-weight: 600;
+    color: var(--text);
+    letter-spacing: -0.02em;
+}
+
+.logo:hover {
+    color: var(--accent);
+}
+
+.cart-link {
+    padding: 0.5rem 1rem;
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    font-size: 0.9rem;
+    transition: all 0.2s;
+}
+
+.cart-link:hover {
+    border-color: var(--accent);
+    background: var(--bg-tertiary);
+}
+
+main {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 3rem 1.5rem;
+    flex: 1;
+    width: 100%;
+}
+
+h1 {
+    margin-bottom: 2rem;
+    font-size: 2rem;
+    font-weight: 700;
+    letter-spacing: -0.03em;
+}
+
+h2 {
+    font-size: 1.25rem;
+    font-weight: 600;
+    letter-spacing: -0.02em;
+}
+
+.product-grid {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 1.5rem;
+}
+
+@media (max-width: 1024px) {
+    .product-grid {
+        grid-template-columns: repeat(2, 1fr);
+    }
+}
+
+@media (max-width: 640px) {
+    .product-grid {
+        grid-template-columns: 1fr;
+    }
+}
+
+.product-card {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    overflow: hidden;
+    transition: all 0.3s ease;
+    cursor: pointer;
+}
+
+.product-card:hover {
+    transform: translateY(-4px);
+    border-color: var(--accent);
+    box-shadow: 0 12px 24px rgba(0, 0, 0, 0.5);
+}
+
+.product-card img {
+    width: 100%;
+    aspect-ratio: 1;
+    object-fit: cover;
+    background: var(--bg-tertiary);
+}
+
+.product-card h2 {
+    font-size: 1.125rem;
+    padding: 1.25rem 1.25rem 0.5rem;
+    color: var(--text);
+}
+
+.product-card p {
+    padding: 0 1.25rem 1.25rem;
+    color: var(--accent);
+    font-weight: 600;
+    font-size: 1.125rem;
+}
+
+.product-detail {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 4rem;
+    max-width: 1200px;
+}
+
+@media (max-width: 768px) {
+    .product-detail {
+        grid-template-columns: 1fr;
+        gap: 2rem;
+    }
+}
+
+.product-image {
+    width: 100%;
+}
+
+.product-image img {
+    width: 100%;
+    border-radius: 16px;
+    background: var(--bg-secondary);
+}
+
+.product-info {
+    display: flex;
+    flex-direction: column;
+}
+
+.product-info h1 {
+    margin-bottom: 1rem;
+}
+
+.product-price {
+    font-size: 2rem;
+    color: var(--accent);
+    font-weight: 700;
+    margin-bottom: 1.5rem;
+}
+
+.product-description {
+    color: var(--text-muted);
+    margin-bottom: 2rem;
+    line-height: 1.7;
+}
+
+form label {
+    display: block;
+    margin-bottom: 0.75rem;
+    font-weight: 600;
+    font-size: 0.9rem;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    color: var(--text-muted);
+}
+
+select, input[type="number"] {
+    width: 100%;
+    padding: 0.875rem 1rem;
+    background: var(--bg-tertiary);
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    color: var(--text);
+    font-size: 1rem;
+    margin-bottom: 1.5rem;
+    transition: border-color 0.2s;
+    -moz-appearance: textfield;
+}
+
+input[type="number"]::-webkit-outer-spin-button,
+input[type="number"]::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+}
+
+select:focus, input[type="number"]:focus {
+    outline: none;
+    border-color: var(--accent);
+}
+
+.quantity-control {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    margin-bottom: 1.5rem;
+}
+
+.qty-btn {
+    width: 40px;
+    height: 40px;
+    background: var(--bg-tertiary);
+    border: 1px solid var(--border);
+    border-radius: 6px;
+    color: var(--text);
+    font-size: 1.25rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.qty-btn:hover {
+    border-color: var(--accent);
+    background: var(--accent);
+    color: white;
+}
+
+.qty-display {
+    width: 80px;
+    text-align: center;
+    padding: 0.75rem;
+    background: var(--bg-tertiary);
+    border: 1px solid var(--border);
+    border-radius: 6px;
+    color: var(--text);
+    font-size: 1rem;
+    margin: 0;
+}
+
+.qty-input {
+    width: 80px;
+    padding: 0.5rem;
+    background: var(--bg-tertiary);
+    border: 1px solid var(--border);
+    border-radius: 6px;
+    color: var(--text);
+    font-size: 0.95rem;
+    -moz-appearance: textfield;
+}
+
+.qty-input::-webkit-outer-spin-button,
+.qty-input::-webkit-inner-spin-button {
+    -webkit-appearance: none;
+    margin: 0;
+}
+
+.btn-primary {
+    background: var(--accent);
+    color: white;
+    border: none;
+    padding: 1rem 2rem;
+    font-size: 1rem;
+    font-weight: 600;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s;
+    width: 100%;
+}
+
+.btn-primary:hover {
+    background: var(--accent-hover);
+    transform: translateY(-1px);
+}
+
+.btn-secondary {
+    display: inline-block;
+    padding: 0.875rem 1.75rem;
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    color: var(--text);
+    font-weight: 500;
+    transition: all 0.2s;
+}
+
+.btn-secondary:hover {
+    border-color: var(--accent);
+    background: var(--bg-tertiary);
+}
+
+.btn-remove {
+    background: transparent;
+    border: 1px solid var(--border);
+    color: var(--text-muted);
+    width: 32px;
+    height: 32px;
+    border-radius: 6px;
+    cursor: pointer;
+    font-size: 1.25rem;
+    line-height: 1;
+    transition: all 0.2s;
+}
+
+.btn-remove:hover {
+    border-color: var(--error);
+    color: var(--error);
+    background: rgba(239, 68, 68, 0.1);
+}
+
+.cart-table {
+    width: 100%;
+    border-collapse: collapse;
+    margin-bottom: 2rem;
+    background: var(--bg-secondary);
+    border-radius: 12px;
+    overflow: hidden;
+}
+
+.cart-table th,
+.cart-table td {
+    padding: 1.25rem;
+    text-align: left;
+    border-bottom: 1px solid var(--border);
+}
+
+.cart-table thead {
+    background: var(--bg-tertiary);
+}
+
+.cart-table th {
+    color: var(--text-muted);
+    font-weight: 600;
+    font-size: 0.875rem;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+}
+
+.cart-table tbody tr:last-child td {
+    border-bottom: none;
+}
+
+.cart-table tfoot td {
+    border-top: 2px solid var(--border);
+    font-size: 1.125rem;
+    padding: 1.5rem 1.25rem;
+}
+
+.cart-thumb {
+    width: 60px;
+    height: 60px;
+    object-fit: cover;
+    border-radius: 8px;
+    vertical-align: middle;
+    margin-right: 1rem;
+    background: var(--bg-tertiary);
+}
+
+.cart-actions {
+    display: flex;
+    gap: 1rem;
+    justify-content: flex-end;
+}
+
+.empty-cart {
+    color: var(--text-muted);
+    text-align: center;
+    padding: 4rem;
+    font-size: 1.125rem;
+}
+
+.checkout-summary {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    padding: 2rem;
+    margin-bottom: 2rem;
+}
+
+.checkout-summary h2 {
+    margin-bottom: 1.5rem;
+}
+
+.success-message, .error-message {
+    text-align: center;
+    padding: 4rem 2rem;
+    max-width: 600px;
+    margin: 0 auto;
+}
+
+.success-message h1 {
+    color: var(--success);
+    margin-bottom: 1rem;
+}
+
+.error-message h1 {
+    color: var(--error);
+    margin-bottom: 1rem;
+}
+
+.success-message p, .error-message p {
+    color: var(--text-muted);
+    margin-bottom: 0.75rem;
+    line-height: 1.7;
+}
+
+.success-message .btn-primary, .error-message .btn-primary {
+    margin-top: 2rem;
+    max-width: 300px;
+}
+
+footer {
+    background: var(--bg-secondary);
+    border-top: 1px solid var(--border);
+    padding: 2rem;
+    text-align: center;
+    color: var(--text-muted);
+    font-size: 0.9rem;
+}
+
+@media (max-width: 768px) {
+    main {
+        padding: 2rem 1.5rem;
+    }
+
+    h1 {
+        font-size: 1.75rem;
+    }
+
+    .cart-table {
+        font-size: 0.875rem;
+    }
+
+    .cart-table th,
+    .cart-table td {
+        padding: 0.875rem;
+    }
+
+    .cart-thumb {
+        width: 50px;
+        height: 50px;
+    }
+}
diff --git a/schema.sql b/schema.sql
index 466bddd..38f312b 100644
--- a/schema.sql
+++ b/schema.sql
@@ -47,10 +47,10 @@ CREATE TABLE IF NOT EXISTS order_items (
 
 -- 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;
+    ('linux-tux-tee', 'Linux Tux Tee', 'Classic penguin on a comfy tee. 100% cotton.', 2500, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=Linux+Tux+Tee'),
+    ('btw-i-use-arch-hoodie', 'BTW I Use Arch Hoodie', 'Let everyone know. Heavyweight fleece.', 4500, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=Arch+Hoodie'),
+    ('foss-freedom-cap', 'FOSS Freedom Cap', 'Embroidered dad hat for free software enjoyers.', 2000, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=FOSS+Cap')
+ON CONFLICT (slug) DO UPDATE SET image_url = EXCLUDED.image_url;
 
 INSERT INTO variants (product_id, size, sort_order) VALUES
     (1, 'S', 1), (1, 'M', 2), (1, 'L', 3), (1, 'XL', 4),
diff --git a/schema.sqlite.sql b/schema.sqlite.sql
new file mode 100644
index 0000000..f167e49
--- /dev/null
+++ b/schema.sqlite.sql
@@ -0,0 +1,60 @@
+CREATE TABLE IF NOT EXISTS products (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    slug TEXT UNIQUE NOT NULL,
+    name TEXT NOT NULL,
+    description TEXT,
+    price INTEGER NOT NULL,
+    image_url TEXT,
+    active INTEGER DEFAULT 1,
+    printful_product_id TEXT,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS variants (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
+    size TEXT NOT NULL,
+    printful_variant_id TEXT,
+    stock INTEGER DEFAULT 0,
+    sort_order INTEGER DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS orders (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    stripe_session_id TEXT UNIQUE,
+    stripe_payment_intent TEXT,
+    email TEXT,
+    status TEXT DEFAULT 'pending',
+    total INTEGER NOT NULL,
+    shipping_name TEXT,
+    shipping_address TEXT,
+    printful_order_id TEXT,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS order_items (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    order_id INTEGER NOT NULL 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
+);
+
+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, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=Linux+Tux+Tee'),
+    ('btw-i-use-arch-hoodie', 'BTW I Use Arch Hoodie', 'Let everyone know. Heavyweight fleece.', 4500, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=Arch+Hoodie'),
+    ('foss-freedom-cap', 'FOSS Freedom Cap', 'Embroidered dad hat for free software enjoyers.', 2000, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=FOSS+Cap')
+ON CONFLICT(slug) DO UPDATE SET image_url = excluded.image_url;
+
+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);
+
+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);