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
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'),