shop.tonybtw.com

shop.tonybtw.com

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

added infra for stripe and products

Commit
4acbd2bbd4d659ceecef779a50cb2b498f5c889a
Parent
482ca2a
Author
tonybanters <tonybanters@gmail.com>
Date
2026-02-20 04:49:39

Diff

diff --git a/internal/handlers/shop.go b/internal/handlers/shop.go
index 088e07b..525b035 100644
--- a/internal/handlers/shop.go
+++ b/internal/handlers/shop.go
@@ -54,10 +54,16 @@ func (h *Shop_Handler) Show_Product(w http.ResponseWriter, r *http.Request, slug
 		return
 	}
 
+	images, err := models.Get_Product_Images(h.ctx.DB, product.ID)
+	if err != nil {
+		http.Error(w, "Failed to load images: "+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)
+	views.Product(*product, variants, images, cart_count).Render(r.Context(), w)
 }
diff --git a/internal/models/product.go b/internal/models/product.go
index 7bf8853..1172ba2 100644
--- a/internal/models/product.go
+++ b/internal/models/product.go
@@ -33,6 +33,13 @@ type Product_With_Variant struct {
 	Printful_Variant_ID sql.NullString
 }
 
+type Product_Image struct {
+	ID         int
+	Product_ID int
+	Image_URL  string
+	Sort_Order int
+}
+
 func Get_All_Products(db *sql.DB) ([]Product, error) {
 	rows, err := db.Query(`
 		SELECT id, slug, name, description, price, image_url
@@ -139,3 +146,26 @@ func Get_Variant_By_ID(db *sql.DB, variant_id int) (*Product_With_Variant, error
 	}
 	return &pwv, nil
 }
+
+func Get_Product_Images(db *sql.DB, product_id int) ([]Product_Image, error) {
+	rows, err := db.Query(`
+		SELECT id, product_id, image_url, sort_order
+		FROM product_images
+		WHERE product_id = $1
+		ORDER BY sort_order
+	`, product_id)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var images []Product_Image
+	for rows.Next() {
+		var img Product_Image
+		if err := rows.Scan(&img.ID, &img.Product_ID, &img.Image_URL, &img.Sort_Order); err != nil {
+			return nil, err
+		}
+		images = append(images, img)
+	}
+	return images, rows.Err()
+}
diff --git a/internal/views/helpers.templ b/internal/views/helpers.templ
index 80819a9..88490d2 100644
--- a/internal/views/helpers.templ
+++ b/internal/views/helpers.templ
@@ -1,11 +1,19 @@
 package views
 
-import "fmt"
+import (
+	"encoding/json"
+	"fmt"
+)
 
 func Format_Price(cents int) string {
 	dollars := float64(cents) / 100.0
 	return fmt.Sprintf("$%.2f", dollars)
 }
 
+func to_json(v any) string {
+	b, _ := json.Marshal(v)
+	return string(b)
+}
+
 templ Dummy() {
 }
diff --git a/internal/views/product.templ b/internal/views/product.templ
index bdec511..0055a57 100644
--- a/internal/views/product.templ
+++ b/internal/views/product.templ
@@ -5,10 +5,33 @@ import (
 	"shop.tonybtw.com/internal/models"
 )
 
-templ Product(product models.Product, variants []models.Variant, cart_count int) {
+func get_all_images(product models.Product, images []models.Product_Image) []string {
+	var urls []string
+	urls = append(urls, product.Image_URL)
+	for _, img := range images {
+		urls = append(urls, img.Image_URL)
+	}
+	return urls
+}
+
+templ Product(product models.Product, variants []models.Variant, images []models.Product_Image, cart_count int) {
 	@Layout(product.Name) {
 		<div class="product-detail">
-			<img src={ product.Image_URL } alt={ product.Name } class="product-image"/>
+			<div class="product-gallery" x-data={ fmt.Sprintf("{ active: 0, images: %s }", to_json(get_all_images(product, images))) }>
+				<img :src="images[active]" :alt="active" class="gallery-main"/>
+				if len(images) > 0 {
+					<div class="gallery-thumbs">
+						<template x-for="(img, index) in images" :key="index">
+							<img
+								:src="img"
+								:class="{ 'active': active === index }"
+								@click="active = index"
+								class="gallery-thumb"
+							/>
+						</template>
+					</div>
+				}
+			</div>
 			<div class="product-info">
 				<h1>{ product.Name }</h1>
 				<p class="product-price">{ Format_Price(product.Price) }</p>
diff --git a/public/static/style.css b/public/static/style.css
index 73cba66..b64b7a1 100644
--- a/public/static/style.css
+++ b/public/static/style.css
@@ -193,14 +193,41 @@ h2 {
     }
 }
 
-.product-image {
+.product-gallery {
     width: 100%;
 }
 
-.product-image img {
+.gallery-main {
     width: 100%;
     border-radius: 16px;
     background: var(--bg-secondary);
+    aspect-ratio: 1;
+    object-fit: cover;
+}
+
+.gallery-thumbs {
+    display: flex;
+    gap: 0.75rem;
+    margin-top: 1rem;
+}
+
+.gallery-thumb {
+    width: 80px;
+    height: 80px;
+    object-fit: cover;
+    border-radius: 8px;
+    cursor: pointer;
+    border: 2px solid var(--border);
+    transition: border-color 0.2s;
+    background: var(--bg-secondary);
+}
+
+.gallery-thumb:hover {
+    border-color: var(--accent);
+}
+
+.gallery-thumb.active {
+    border-color: var(--accent);
 }
 
 .product-info {
diff --git a/schema.sql b/schema.sql
index 38f312b..256e984 100644
--- a/schema.sql
+++ b/schema.sql
@@ -45,6 +45,13 @@ CREATE TABLE IF NOT EXISTS order_items (
     price INTEGER NOT NULL  -- cents at time of purchase
 );
 
+CREATE TABLE IF NOT EXISTS product_images (
+    id SERIAL PRIMARY KEY,
+    product_id INTEGER REFERENCES products(id) ON DELETE CASCADE,
+    image_url VARCHAR(500) NOT NULL,
+    sort_order INTEGER DEFAULT 0
+);
+
 -- 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, 'https://placehold.co/600x600/1a1a1a/3b82f6?text=Linux+Tux+Tee'),