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.
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">×</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>© <?= 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">×</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>© 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);