--- /dev/null
+{
+ "permissions": {
+ "allow": [
+ "Bash(deno check *)"
+ ]
+ }
+}
--- /dev/null
+PRINTIFY_TOKEN="eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIzN2Q0YmQzMDM1ZmUxMWU5YTgwM2FiN2VlYjNjY2M5NyIsImp0aSI6IjAyZDc3N2Q2MjZhYTc5MjU4ZWYzZDUwZTU4MjdkYzBiYmE2Yzg5ZDliNTUwNmVhOWEzMTkwNmU5MzAzMDBjM2E1MDgwNjc2YjFlOTRjNzkyIiwiaWF0IjoxNzc4ODY2OTI0LjgwNTkzOCwibmJmIjoxNzc4ODY2OTI0LjgwNTk0LCJleHAiOjE4MTA0MDI5MjQuNzk5MDQ2LCJzdWIiOiIyMzE4MjgyMyIsInNjb3BlcyI6WyJzaG9wcy5tYW5hZ2UiLCJzaG9wcy5yZWFkIiwiY2F0YWxvZy5yZWFkIiwib3JkZXJzLnJlYWQiLCJvcmRlcnMud3JpdGUiLCJwcm9kdWN0cy5yZWFkIiwicHJvZHVjdHMud3JpdGUiLCJ3ZWJob29rcy5yZWFkIiwid2ViaG9va3Mud3JpdGUiLCJ1cGxvYWRzLnJlYWQiLCJ1cGxvYWRzLndyaXRlIiwicHJpbnRfcHJvdmlkZXJzLnJlYWQiLCJ1c2VyLmluZm8iXX0.JPSB6YJ5-u1NGBLVgtKIDigzDvXuLhnfeb5MdRWwrIXL4gckgjgLkuSoseExFIzmgvOjeUAovmavchi9eaOULoq6EOis-yT2E-2he3xIAVI468Kzj3xKj7ARfnXOSVh45ISl9eaRXfyCX67-PrQEajAy0zOrYZnBKm5uUhSb3Rav-fkSYWtPHvPKH1YPo_bjklavDuPKri0QK8p9QHI2M9jSRmqKTVK7F8n2Nf5ZwGKHUr1bXIiQHbyBBCqE8sg3G3PpUSmS3edMiIgMSuQVFXm_agaiV8hm_oOe06DMy8n5UQ83_wOBZLzDxMHm5wf-frXLo78n7MuG_p_aI5XJQ_WwPvfjCbYoCv_7_Fk_v2NEfe62wVcOeq5mx6KkqEY5tI4eJ5wUrlfkpS6AIVkHFs6Obwc17-0oUZCMSgpWSC1_CQh-fRv_JadM42oIC6b81vTMav39wY2mnANHZEKFu6-Z_TKEmu7XLvmF6qWrtgX1ECDKXW-qsBRIvhqHLIu4rslZQYrKqC23rXPEOqnybsrluklWXi8gNEsFWiS4K4aNDdPqMH6HxG5WNaAtOyrB46V7KS39CcARVR1jyp3wfCGXzBtQoBDCyrcXfFD4hGOUX2Flqsz_ZRzx9XFvMhGiB5bPx6tE8_JRpDbGRwm4WcLOq82sUjBmL8z6sAypoY8"
+
+# Optional — if blank the first shop on your account is used automatically
+# PRINTIFY_SHOP_ID=
+
+# STRIPE_SECRET_KEY=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
+# STRIPE_PUBLIC_KEY=pk_live_51TXYHPINmtdaSyedZUqanCXq3hBeo6Y1PdlEssWAalJnmz7yiZGqmq1n8uvQ5K9HdxdocSDG9SRsYPnvjZbOX1Gl00FrbqsbH2
+
+STRIPE_PUBLIC_KEY=pk_test_51TXYHlISecj1VtMPXEHcMIBoQtfg8QHR8yw0zeRdsTOTBkUM9NFDvDUr5Ho72RwGYP3g3gaNKzzbSuXgZJDNPMDT00t9PPl7wV
+STRIPE_SECRET_KEY=sk_test_51TXYHlISecj1VtMP0CQ1MKUvWtMZDwqTULl8LwnDGB0gf0mqqUaFjgwPW3LlHDKN7qQAjL5laBn55nSJMRZnJiYn00ashsdHOZ
+
+# Store display name and default product tagline
+STORE_NAME=Two Witness Shop
+STORE_TAGLINE=Wearable faith. Designs rooted in Scripture, made to spark conversation.
+
+PORT=8000
+
--- /dev/null
+# Printify — https://printify.com/app/account/api
+PRINTIFY_TOKEN=your_api_token_here
+
+# Stripe — https://dashboard.stripe.com/apikeys
+STRIPE_SECRET_KEY=sk_test_...
+STRIPE_PUBLIC_KEY=pk_test_...
+
+# Optional — if blank the first shop on your account is used automatically
+# PRINTIFY_SHOP_ID=
+
+# Store display name and default product tagline
+STORE_NAME=My Store
+STORE_TAGLINE=Quality design, made to order.
+
+PORT=8000
--- /dev/null
+# Printify Store
+
+A minimal, fully functional print-on-demand storefront built with Deno, Hono, and Deno KV. Connects directly to the Printify API for product fulfillment and Stripe for payment collection. No framework overhead, no database to manage, no servers to provision.
+
+---
+
+## How it works
+
+The store sits between your customers and Printify:
+
+```
+Customer pays Stripe ($35)
+ → Your server confirms payment
+ → Your server submits order to Printify
+ → Printify charges your card on file (~$12 base cost)
+ → Printify prints and ships directly to your customer
+ → You keep the margin (~$23)
+```
+
+Everything is automated. You set your prices in Printify, publish your products, and the store handles the rest.
+
+---
+
+## Stack
+
+| Layer | Technology |
+|---|---|
+| Runtime | [Deno 2](https://deno.com) |
+| Web framework | [Hono](https://hono.dev) with JSX |
+| Database | [Deno KV](https://docs.deno.com/kv/manual) (built-in) |
+| Fulfillment | [Printify API](https://developers.printify.com) |
+| Payments | [Stripe](https://stripe.com) |
+
+---
+
+## Prerequisites
+
+- **Deno 2** — [install](https://docs.deno.com/runtime/getting_started/installation/)
+- **Printify account** — [printify.com](https://printify.com) with at least one published product
+- **Stripe account** — [stripe.com](https://stripe.com), free to create
+
+---
+
+## Setup
+
+### 1. Clone and enter the project
+
+```bash
+git clone <your-repo>
+cd printify
+```
+
+### 2. Create your environment file
+
+```bash
+cp .env.example .env
+```
+
+Open `.env` and fill in your keys:
+
+```env
+# Required
+PRINTIFY_TOKEN=your_printify_api_token
+STRIPE_SECRET_KEY=sk_live_...
+STRIPE_PUBLIC_KEY=pk_live_...
+
+# Optional — auto-discovered if blank
+# PRINTIFY_SHOP_ID=
+
+# Optional — cosmetic
+STORE_NAME=My Store
+STORE_TAGLINE=Quality design, made to order.
+```
+
+### 3. Run
+
+```bash
+deno task dev
+```
+
+The store is now running at [http://localhost:8000](http://localhost:8000).
+
+---
+
+## Getting your keys
+
+### Printify API token
+
+1. Log in to [printify.com](https://printify.com)
+2. Go to **My Profile → Connections → API**
+3. Click **Generate** to create a token
+4. Copy it into `PRINTIFY_TOKEN`
+
+The shop ID is discovered automatically from your token — you do not need to set `PRINTIFY_SHOP_ID` unless you have multiple shops and want to target a specific one.
+
+### Stripe keys
+
+1. Log in to [dashboard.stripe.com](https://dashboard.stripe.com)
+2. Go to **Developers → API keys**
+3. Copy the **Publishable key** → `STRIPE_PUBLIC_KEY`
+4. Copy the **Secret key** → `STRIPE_SECRET_KEY`
+
+Use **test keys** (`pk_test_...` / `sk_test_...`) during development. Switch to live keys when you go live.
+
+### Printify billing
+
+For the payment flow to be fully automated, Printify must be able to charge you automatically when an order is submitted:
+
+1. In Printify, go to **Billing → Payment methods**
+2. Add a credit or debit card
+3. Enable **Automatic billing**
+
+When a customer pays via Stripe, your server immediately submits the order to Printify. Printify charges your card for the base cost and ships to the customer. No manual steps needed.
+
+---
+
+## Printify product setup
+
+Only published products with enabled variants appear in the store. To set up a product correctly:
+
+1. Create a product in Printify and upload your design
+2. Under **Variants**, enable only the colors and sizes you want to sell — disabled variants are automatically hidden in the store
+3. Set your retail prices for each variant
+4. Mark one color as the **default** — that color's image and price will be shown when customers first land on the product page
+5. **Publish** the product to your store
+
+The store reads exactly what Printify exposes — no manual syncing needed.
+
+---
+
+## Dry-run mode
+
+Test the full checkout flow without submitting real orders to Printify or charging Stripe:
+
+```bash
+deno task dev --dry-run
+```
+
+In dry-run mode:
+- A yellow banner appears on the checkout and success pages
+- The Stripe payment form is replaced with a plain submit button
+- Clicking "Place Order" logs the full Printify order payload to your terminal
+- The cart is cleared and the success page is shown — identical to a real order
+- Nothing is sent to Stripe or Printify
+
+Use this to verify your shipping data, line items, and variant IDs are correct before going live.
+
+---
+
+## Project structure
+
+```
+printify/
+├── main.ts # Entry point — wires app and starts server
+├── deno.json # Deno config, import map, JSX transform
+├── .env.example # Environment variable template
+└── src/
+ ├── config.ts # Runtime flags (e.g. --dry-run)
+ ├── types.ts # Shared TypeScript interfaces
+ ├── client/
+ │ └── printify.ts # Printify API wrapper
+ ├── kv/
+ │ └── cart.ts # Cart and shipping — Deno KV operations
+ ├── middleware/
+ │ └── session.ts # Cookie-based session ID
+ ├── components/
+ │ ├── Layout.tsx # HTML shell, CSS design system, global JS
+ │ └── ProductCard.tsx # Product card with color swatches and quick-add
+ └── routes/
+ ├── shop.tsx # GET / — product grid
+ ├── product.tsx # GET /products/:id — product detail
+ ├── cart.tsx # /cart — view, add, update, remove
+ └── checkout.tsx # /checkout — Stripe payment + Printify order
+```
+
+---
+
+## Routes reference
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/` | Product grid |
+| `GET` | `/products/:id` | Product detail with variant selector |
+| `GET` | `/cart` | Cart |
+| `POST` | `/cart/add` | Add item (from product page) |
+| `POST` | `/cart/add-ajax` | Add item (from quick-add, returns JSON) |
+| `POST` | `/cart/update` | Update quantity |
+| `POST` | `/cart/remove` | Remove item |
+| `GET` | `/checkout` | Checkout page |
+| `POST` | `/checkout/intent` | Create Stripe payment intent |
+| `POST` | `/checkout/shipping` | Persist shipping before Stripe redirect |
+| `GET` | `/checkout/done` | Stripe return URL — confirms payment, submits Printify order |
+| `GET` | `/checkout/success` | Order confirmation |
+| `POST` | `/checkout/dry-run` | Dry-run order submit (logs payload, no API calls) |
+
+---
+
+## Payment flow in detail
+
+```
+1. Customer fills shipping form and card details (Stripe Payment Element)
+2. Browser calls POST /checkout/shipping — shipping data saved to Deno KV
+3. Browser calls stripe.confirmPayment() — Stripe charges the customer
+4. Stripe redirects to GET /checkout/done?payment_intent=xxx
+5. Server calls Stripe API to verify payment status === "succeeded"
+6. Server retrieves shipping from Deno KV (keyed by session ID)
+7. Server calls Printify API to create order
+8. Printify charges your card on file for the base cost and queues fulfillment
+9. Server clears cart and shipping from KV
+10. Customer is redirected to /checkout/success with their order ID
+```
+
+If Printify fails after payment is already collected, the full cart and shipping details are logged to the terminal so you can create the order manually. The customer has been charged; nobody loses the order.
+
+---
+
+## Deployment
+
+The easiest deployment target for a Deno app is [Deno Deploy](https://deno.com/deploy).
+
+1. Push your code to GitHub
+2. Create a new project at [dash.deno.com](https://dash.deno.com)
+3. Connect your repository and set the entry point to `main.ts`
+4. Add your environment variables in the project settings
+5. Deploy
+
+Deno KV is available natively on Deno Deploy — no additional database setup required.
+
+> **Note:** Remove `--env-file` from the `start` task before deploying, as environment variables are injected by the platform rather than loaded from a file.
+
+---
+
+## Environment variables
+
+| Variable | Required | Description |
+|---|---|---|
+| `PRINTIFY_TOKEN` | Yes | Printify API token |
+| `STRIPE_SECRET_KEY` | Yes | Stripe secret key (`sk_live_...` or `sk_test_...`) |
+| `STRIPE_PUBLIC_KEY` | Yes | Stripe publishable key (`pk_live_...` or `pk_test_...`) |
+| `PRINTIFY_SHOP_ID` | No | Defaults to the first shop on your account |
+| `STORE_NAME` | No | Display name shown in the nav and page titles. Default: `Store` |
+| `STORE_TAGLINE` | No | Short description shown on every product page. Default: `Quality design, made to order.` |
+| `PORT` | No | Port to listen on. Default: `8000` |
+
+---
+
+## Design
+
+The UI follows a minimal, high-contrast aesthetic — generous whitespace, system font stack, black CTAs. Designed to get out of the way of the product photography.
+
+- **Responsive** — single column on mobile, multi-column grid on larger screens
+- **No client-side framework** — cart operations are plain HTML form posts; only the product variant selector, quick-add, and Stripe payment form use JavaScript
+- **Quick-add** — tap the `+` on any product card to add a size without leaving the grid
+- **Color swatches** — each card shows a dot per available color so customers know the range before clicking through
+- **Sticky add bar** — on mobile, an "Add to Bag" button stays fixed at the bottom of the product page as the customer scrolls through options
--- /dev/null
+{
+ "tasks": {
+ "dev": "deno run --allow-net --allow-env --allow-read --env-file --unstable-kv main.ts",
+ "start": "deno run --allow-net --allow-env --allow-read --env-file --unstable-kv main.ts"
+ },
+ "imports": {
+ "hono": "npm:hono@^4",
+ "hono/logger": "npm:hono@^4/logger",
+ "hono/cookie": "npm:hono@^4/cookie",
+ "hono/factory": "npm:hono@^4/factory",
+ "hono/jsx": "npm:hono@^4/jsx"
+ },
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ }
+}
--- /dev/null
+{
+ "version": "5",
+ "specifiers": {
+ "npm:hono@4": "4.12.18"
+ },
+ "npm": {
+ "hono@4.12.18": {
+ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="
+ }
+ },
+ "workspace": {
+ "dependencies": [
+ "npm:hono@4"
+ ]
+ }
+}
--- /dev/null
+import { Hono } from "hono";
+import { logger } from "hono/logger";
+import { session } from "./src/middleware/session.ts";
+import { shopRoute } from "./src/routes/shop.tsx";
+import { productRoute } from "./src/routes/product.tsx";
+import { cartRoute } from "./src/routes/cart.tsx";
+import { checkoutRoute } from "./src/routes/checkout.tsx";
+
+const app = new Hono();
+
+app.use("*", logger());
+app.use("*", session);
+
+app.route("/", shopRoute);
+app.route("/products", productRoute);
+app.route("/cart", cartRoute);
+app.route("/checkout", checkoutRoute);
+
+app.notFound((c) =>
+ c.html(
+ `<html><body style="font-family:sans-serif;text-align:center;padding:80px">
+ <h1 style="font-size:28px">Page not found</h1>
+ <a href="/">← Home</a>
+ </body></html>`,
+ 404
+ )
+);
+
+const port = Number(Deno.env.get("PORT") ?? 8000);
+console.log(`\n Store running at http://localhost:${port}\n`);
+
+Deno.serve({ port }, app.fetch);
--- /dev/null
+import type {
+ PrintifyProduct,
+ PrintifyProductsResponse,
+ ShippingAddress,
+ CartItem,
+} from "../types.ts";
+
+const BASE = "https://api.printify.com/v1";
+
+interface PrintifyShop {
+ id: number;
+ title: string;
+}
+
+export class PrintifyClient {
+ private resolvedShopId: string | null = null;
+
+ constructor(private token: string, private shopId: string) {}
+
+ private async req<T>(path: string, init?: RequestInit): Promise<T> {
+ const res = await fetch(`${BASE}${path}`, {
+ ...init,
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`Printify ${res.status}: ${text}`);
+ }
+ return res.json() as Promise<T>;
+ }
+
+ // Auto-resolves shop ID from env or falls back to the first shop on the account
+ private async shop(): Promise<string> {
+ if (this.resolvedShopId) return this.resolvedShopId;
+ if (this.shopId) {
+ this.resolvedShopId = this.shopId;
+ return this.resolvedShopId;
+ }
+ const shops = await this.req<PrintifyShop[]>("/shops.json");
+ if (!shops.length) throw new Error("No Printify shops found for this token.");
+ this.resolvedShopId = String(shops[0].id);
+ console.log(`Printify: using shop "${shops[0].title}" (${this.resolvedShopId})`);
+ return this.resolvedShopId;
+ }
+
+ async getProducts(page = 1, limit = 20): Promise<PrintifyProductsResponse> {
+ const id = await this.shop();
+ return this.req(`/shops/${id}/products.json?page=${page}&limit=${limit}`);
+ }
+
+ async getProduct(productId: string): Promise<PrintifyProduct> {
+ const id = await this.shop();
+ return this.req(`/shops/${id}/products/${productId}.json`);
+ }
+
+ async createOrder(params: {
+ externalId: string;
+ items: CartItem[];
+ shipping: ShippingAddress;
+ }): Promise<{ id: string }> {
+ const id = await this.shop();
+ return this.req(`/shops/${id}/orders.json`, {
+ method: "POST",
+ body: JSON.stringify({
+ external_id: params.externalId,
+ send_shipping_notification: true,
+ line_items: params.items.map((i) => ({
+ product_id: i.productId,
+ variant_id: i.variantId,
+ quantity: i.quantity,
+ })),
+ shipping_method: 1,
+ address_to: {
+ first_name: params.shipping.firstName,
+ last_name: params.shipping.lastName,
+ email: params.shipping.email,
+ phone: params.shipping.phone,
+ country: params.shipping.country,
+ region: params.shipping.region,
+ address1: params.shipping.address1,
+ address2: params.shipping.address2,
+ city: params.shipping.city,
+ zip: params.shipping.zip,
+ },
+ }),
+ });
+ }
+}
+
+let _client: PrintifyClient | null = null;
+
+export function getPrintify(): PrintifyClient {
+ if (!_client) {
+ const token = Deno.env.get("PRINTIFY_TOKEN") ?? "";
+ const shopId = Deno.env.get("PRINTIFY_SHOP_ID") ?? ""; // optional — auto-discovered if blank
+ _client = new PrintifyClient(token, shopId);
+ }
+ return _client;
+}
--- /dev/null
+import type { FC, Child } from "hono/jsx";
+
+interface Props {
+ title?: string;
+ cartCount?: number;
+ children?: Child;
+}
+
+const storeName = Deno.env.get("STORE_NAME") ?? "Store";
+
+export const Layout: FC<Props> = ({ title, cartCount = 0, children }) => (
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>{title ? `${title} — ${storeName}` : storeName}</title>
+ <style dangerouslySetInnerHTML={{ __html: css }} />
+ </head>
+ <body>
+ <nav>
+ <a href="/" class="logo">
+ {storeName}
+ </a>
+ <a href="/cart" class="cart-link">
+ Bag
+ {cartCount > 0 && (
+ <span class="cart-badge">{cartCount}</span>
+ )}
+ </a>
+ </nav>
+ <main>{children}</main>
+ <script dangerouslySetInnerHTML={{ __html: globalJs }} />
+ </body>
+ </html>
+);
+
+const globalJs = `
+function toggleQuickAdd(event, btn) {
+ event.preventDefault();
+ event.stopPropagation();
+ const card = btn.closest('.card');
+ const isOpen = card.classList.contains('qa-open');
+ document.querySelectorAll('.card.qa-open').forEach(c => c.classList.remove('qa-open'));
+ if (!isOpen) card.classList.add('qa-open');
+}
+
+document.addEventListener('click', () => {
+ document.querySelectorAll('.card.qa-open').forEach(c => c.classList.remove('qa-open'));
+});
+
+async function quickAdd(event, btn) {
+ event.preventDefault();
+ event.stopPropagation();
+ const productId = btn.dataset.product;
+ const variantId = btn.dataset.variant;
+ const original = btn.textContent;
+ btn.textContent = '✓';
+ btn.classList.add('added');
+ btn.disabled = true;
+ try {
+ const form = new FormData();
+ form.append('productId', productId);
+ form.append('variantId', variantId);
+ const res = await fetch('/cart/add-ajax', { method: 'POST', body: form });
+ if (!res.ok) throw new Error();
+ const { count } = await res.json();
+ let badge = document.querySelector('.cart-badge');
+ if (!badge) {
+ badge = document.createElement('span');
+ badge.className = 'cart-badge';
+ document.querySelector('.cart-link').appendChild(badge);
+ }
+ badge.textContent = count;
+ btn.closest('.card').classList.remove('qa-open');
+ } catch (_) {
+ btn.textContent = original;
+ btn.classList.remove('added');
+ btn.disabled = false;
+ return;
+ }
+ setTimeout(() => {
+ btn.textContent = original;
+ btn.classList.remove('added');
+ btn.disabled = false;
+ }, 1400);
+}
+`;
+
+const css = `
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --bg: #fff;
+ --bg-alt: #f5f5f7;
+ --text: #1d1d1f;
+ --muted: #6e6e73;
+ --accent: #0071e3;
+ --border: #d2d2d7;
+ --radius: 18px;
+ --nav: 48px;
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
+}
+
+html { font-size: 16px; scroll-behavior: smooth; }
+body {
+ font-family: var(--font);
+ color: var(--text);
+ background: var(--bg);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ── Nav ──────────────────────────────────── */
+nav {
+ position: sticky; top: 0; z-index: 100;
+ height: var(--nav);
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 0 24px;
+ background: rgba(255,255,255,.85);
+ backdrop-filter: saturate(180%) blur(20px);
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
+ border-bottom: 1px solid var(--border);
+}
+.logo {
+ font-size: 17px; font-weight: 600; letter-spacing: -.4px;
+ color: var(--text); text-decoration: none;
+}
+.cart-link {
+ display: flex; align-items: center; gap: 7px;
+ font-size: 14px; color: var(--text); text-decoration: none;
+ padding: 6px 14px; border-radius: 20px;
+ transition: background .15s;
+}
+.cart-link:hover { background: var(--bg-alt); }
+.cart-badge {
+ background: var(--accent); color: #fff;
+ font-size: 11px; font-weight: 700;
+ padding: 2px 6px; border-radius: 10px;
+ line-height: 1;
+}
+
+main { min-height: calc(100vh - var(--nav)); }
+
+/* ── Product grid ─────────────────────────── */
+.shop {
+ max-width: 1200px; margin: 0 auto; padding: 64px 24px;
+}
+.shop-header { margin-bottom: 48px; }
+.shop-header h1 {
+ font-size: 40px; font-weight: 700; letter-spacing: -1.5px;
+}
+.shop-header p {
+ font-size: 17px; color: var(--muted); margin-top: 10px;
+}
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 20px;
+}
+
+/* ── Product card ─────────────────────────── */
+.card {
+ background: var(--bg-alt); border-radius: var(--radius);
+ overflow: hidden; color: inherit;
+ transition: transform .25s, box-shadow .25s;
+ display: block; position: relative;
+}
+.card:hover { transform: translateY(-3px); box-shadow: 0 12px 40px rgba(0,0,0,.08); }
+.card-img { aspect-ratio: 1; overflow: hidden; position: relative; }
+.card-img-link { display: block; width: 100%; height: 100%; }
+.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .35s; display: block; }
+.card:hover .card-img img { transform: scale(1.04); }
+.card-body { padding: 16px 18px 20px; text-decoration: none; display: block; }
+.card-title { font-size: 15px; font-weight: 500; line-height: 1.35; color: var(--text); }
+.card-price { font-size: 14px; color: var(--muted); margin-top: 5px; }
+.color-dots { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 10px; }
+.color-dot {
+ width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
+ box-shadow: inset 0 0 0 1px rgba(0,0,0,.15), 0 0 0 1.5px var(--bg-alt);
+ display: inline-block;
+}
+.color-dot-more { font-size: 11px; color: var(--muted); line-height: 1; }
+
+/* ── Quick-add panel ──────────────────────── */
+.qa-toggle {
+ position: absolute; bottom: 10px; right: 10px; z-index: 2;
+ width: 30px; height: 30px; border-radius: 50%;
+ background: rgba(255,255,255,.9); border: none; cursor: pointer;
+ font-size: 20px; line-height: 1;
+ display: flex; align-items: center; justify-content: center;
+ backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
+ box-shadow: 0 1px 6px rgba(0,0,0,.15);
+ transition: transform .2s, background .15s;
+ color: var(--text);
+}
+.qa-toggle:hover { background: #fff; }
+.card.qa-open .qa-toggle { transform: rotate(45deg); }
+.quick-add {
+ position: absolute; bottom: 0; left: 0; right: 0;
+ padding: 10px 12px 12px;
+ background: rgba(255,255,255,.95);
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
+ border-top: 1px solid rgba(0,0,0,.06);
+ transform: translateY(100%);
+ transition: transform .2s ease;
+}
+.card.qa-open .quick-add { transform: translateY(0); }
+.quick-label {
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: .6px; color: var(--muted); margin-bottom: 7px;
+}
+.quick-btns { display: flex; flex-wrap: wrap; gap: 5px; }
+.quick-size-btn {
+ padding: 6px 12px; border: 1.5px solid var(--border);
+ border-radius: 6px; font-size: 12px; font-weight: 500;
+ background: #fff; cursor: pointer; transition: all .15s;
+ font-family: inherit; color: var(--text); line-height: 1;
+ min-height: 36px;
+}
+.quick-size-btn:hover,
+.quick-size-btn.added { border-color: var(--text); background: var(--text); color: #fff; }
+.quick-single-btn {
+ width: 100%; padding: 9px; border: none;
+ border-radius: 8px; font-size: 13px; font-weight: 500;
+ background: var(--text); color: #fff; cursor: pointer;
+ transition: opacity .15s; font-family: inherit; min-height: 36px;
+}
+.quick-single-btn:hover { opacity: .8; }
+.quick-single-btn.added { opacity: .6; }
+
+/* ── Product detail ───────────────────────── */
+.product {
+ max-width: 1100px; margin: 0 auto; padding: 60px 24px;
+ display: grid; grid-template-columns: 1fr 1fr; gap: 64px; align-items: start;
+}
+.product-images { display: flex; flex-direction: column; gap: 10px; }
+.main-img {
+ aspect-ratio: 1; border-radius: var(--radius); overflow: hidden;
+ background: var(--bg-alt); cursor: zoom-in;
+}
+.main-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .4s; }
+.main-img:hover img { transform: scale(1.04); }
+.thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
+.thumb {
+ width: 62px; height: 62px; border-radius: 10px; overflow: hidden;
+ background: var(--bg-alt); cursor: pointer;
+ border: 2px solid transparent; transition: border-color .15s;
+ flex-shrink: 0;
+}
+.thumb.active, .thumb:hover { border-color: var(--text); }
+.thumb img { width: 100%; height: 100%; object-fit: cover; }
+
+.product-info { position: sticky; top: calc(var(--nav) + 20px); }
+.product-title { font-size: 30px; font-weight: 700; letter-spacing: -.7px; line-height: 1.2; }
+.product-price {
+ font-size: 22px; font-weight: 500; color: var(--muted); margin-top: 10px;
+}
+.product-tagline {
+ font-size: 15px; color: var(--muted); margin-top: 18px; line-height: 1.55;
+}
+.product-desc-details { margin-top: 10px; }
+.see-more {
+ font-size: 14px; color: var(--accent); cursor: pointer;
+ list-style: none; user-select: none;
+}
+.see-more::-webkit-details-marker { display: none; }
+details[open] .see-more { color: var(--muted); }
+.product-desc-full {
+ font-size: 14px; color: var(--muted); line-height: 1.65; margin-top: 10px;
+}
+.options { margin-top: 28px; display: flex; flex-direction: column; gap: 20px; }
+.option-label {
+ font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px;
+ color: var(--muted); margin-bottom: 8px;
+}
+.opt-select {
+ width: 100%; padding: 11px 36px 11px 14px;
+ border: 1.5px solid var(--border); border-radius: 10px;
+ font-size: 15px; font-family: inherit; color: var(--text);
+ background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236e6e73' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 12px center;
+ appearance: none; cursor: pointer; transition: border-color .15s;
+}
+.opt-select:focus { outline: none; border-color: var(--accent); }
+.add-btn {
+ width: 100%; margin-top: 28px; padding: 15px;
+ background: var(--text); color: #fff;
+ border: none; border-radius: 12px;
+ font-size: 17px; font-weight: 500; cursor: pointer;
+ transition: opacity .15s; font-family: inherit;
+ letter-spacing: -.2px;
+}
+.add-btn:hover { opacity: .8; }
+.add-btn:disabled { opacity: .35; cursor: not-allowed; }
+
+/* ── Dry-run banner ───────────────────────── */
+.dry-run-banner {
+ background: #fff8e6; border-bottom: 1px solid #f5d87e;
+ padding: 10px 24px; font-size: 13px; color: #856404;
+ text-align: center; font-weight: 500;
+}
+
+/* ── Cart ─────────────────────────────────── */
+.cart-page {
+ max-width: 760px; margin: 0 auto; padding: 60px 24px;
+}
+.page-title {
+ font-size: 36px; font-weight: 700; letter-spacing: -1px; margin-bottom: 44px;
+}
+.cart-empty {
+ text-align: center; padding: 80px 0; color: var(--muted);
+}
+.cart-empty p { font-size: 17px; margin-bottom: 20px; }
+.cart-items { border-top: 1px solid var(--border); }
+.cart-item {
+ display: grid;
+ grid-template-columns: 80px 1fr auto;
+ gap: 16px; align-items: center;
+ padding: 20px 0; border-bottom: 1px solid var(--border);
+}
+.cart-item-img {
+ width: 80px; height: 80px; border-radius: 12px; overflow: hidden;
+ background: var(--bg-alt); flex-shrink: 0;
+}
+.cart-item-img img { width: 100%; height: 100%; object-fit: cover; }
+.item-title { font-size: 15px; font-weight: 500; }
+.item-variant { font-size: 13px; color: var(--muted); margin-top: 3px; }
+.item-price { font-size: 14px; color: var(--muted); margin-top: 8px; }
+.item-controls { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
+.qty { display: flex; align-items: center; gap: 12px; }
+.qty-btn {
+ width: 28px; height: 28px; border: 1.5px solid var(--border);
+ border-radius: 50%; background: none; cursor: pointer;
+ font-size: 18px; line-height: 1;
+ display: flex; align-items: center; justify-content: center;
+ transition: border-color .15s; color: var(--text); font-family: inherit;
+}
+.qty-btn:hover { border-color: var(--text); }
+.qty-val { font-size: 15px; min-width: 20px; text-align: center; }
+.remove-btn {
+ font-size: 13px; color: var(--muted); background: none; border: none;
+ cursor: pointer; font-family: inherit; text-decoration: underline;
+}
+.remove-btn:hover { color: var(--text); }
+.cart-footer {
+ margin-top: 36px; display: flex; flex-direction: column;
+ align-items: flex-end; gap: 20px;
+}
+.cart-total { font-size: 24px; font-weight: 600; }
+.cart-total small { font-size: 17px; font-weight: 400; color: var(--muted); margin-right: 8px; }
+.btn-primary {
+ display: inline-block; padding: 15px 40px;
+ background: var(--text); color: #fff;
+ border: none; border-radius: 12px;
+ font-size: 17px; font-weight: 500; cursor: pointer;
+ text-decoration: none; transition: opacity .15s; font-family: inherit;
+ letter-spacing: -.2px;
+}
+.btn-primary:hover { opacity: .8; }
+.btn-ghost {
+ display: inline-block; padding: 15px 40px;
+ background: none; color: var(--text);
+ border: 1.5px solid var(--border); border-radius: 12px;
+ font-size: 17px; font-weight: 500; cursor: pointer;
+ text-decoration: none; transition: border-color .15s; font-family: inherit;
+}
+.btn-ghost:hover { border-color: var(--text); }
+
+/* ── Checkout ─────────────────────────────── */
+.checkout {
+ max-width: 660px; margin: 0 auto; padding: 60px 24px;
+}
+.checkout-grid {
+ max-width: 1100px; margin: 0 auto; padding: 60px 24px;
+ display: grid; grid-template-columns: 1fr 420px; gap: 64px; align-items: start;
+}
+.checkout-form-col {}
+.checkout-summary-col { position: sticky; top: calc(var(--nav) + 20px); }
+.section-title {
+ font-size: 13px; font-weight: 600; text-transform: uppercase;
+ letter-spacing: .6px; color: var(--muted); margin-bottom: 20px;
+}
+.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
+.field label { font-size: 13px; font-weight: 500; color: var(--muted); }
+.field input, .field select {
+ padding: 12px 14px; border: 1.5px solid var(--border); border-radius: 10px;
+ font-size: 15px; font-family: inherit; color: var(--text); background: #fff;
+ transition: border-color .15s; width: 100%; appearance: none;
+}
+.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
+.field input::placeholder { color: var(--border); }
+
+.order-summary {
+ background: var(--bg-alt); border-radius: var(--radius); padding: 24px;
+}
+.summary-items { margin-bottom: 16px; }
+.summary-row {
+ display: flex; justify-content: space-between; align-items: flex-start;
+ font-size: 14px; padding: 8px 0;
+}
+.summary-row-name { flex: 1; color: var(--text); }
+.summary-row-qty { color: var(--muted); margin: 0 12px; }
+.summary-row-price { color: var(--text); font-variant-numeric: tabular-nums; }
+.summary-divider { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
+.summary-total {
+ display: flex; justify-content: space-between;
+ font-size: 16px; font-weight: 600; padding-top: 8px;
+}
+
+.payment-section { margin-top: 36px; }
+#payment-element { margin: 16px 0; min-height: 40px; }
+.pay-btn {
+ width: 100%; padding: 15px;
+ background: var(--text); color: #fff;
+ border: none; border-radius: 12px;
+ font-size: 17px; font-weight: 500; cursor: pointer;
+ transition: opacity .15s; font-family: inherit; letter-spacing: -.2px;
+}
+.pay-btn:hover { opacity: .8; }
+.pay-btn:disabled { opacity: .4; cursor: not-allowed; }
+#payment-message {
+ font-size: 13px; color: #c0392b; margin-top: 10px; text-align: center; display: none;
+}
+
+/* ── Success ──────────────────────────────── */
+.success {
+ max-width: 560px; margin: 0 auto;
+ padding: 100px 24px; text-align: center;
+}
+.success-icon { font-size: 64px; margin-bottom: 24px; }
+.success h1 {
+ font-size: 36px; font-weight: 700; letter-spacing: -1px; margin-bottom: 12px;
+}
+.success p { font-size: 17px; color: var(--muted); line-height: 1.6; }
+.success .order-id {
+ display: inline-block; margin-top: 16px;
+ font-size: 13px; color: var(--muted);
+ background: var(--bg-alt); padding: 6px 14px; border-radius: 20px;
+}
+.success .btn-primary { margin-top: 36px; }
+
+/* ── Utility ──────────────────────────────── */
+.notice {
+ background: var(--bg-alt); border-radius: 12px;
+ padding: 16px 20px; font-size: 14px; color: var(--muted);
+ margin-top: 16px;
+}
+.error-page {
+ max-width: 500px; margin: 100px auto; padding: 0 24px; text-align: center;
+}
+.error-page h1 { font-size: 28px; font-weight: 700; margin-bottom: 10px; }
+.error-page p { color: var(--muted); font-size: 16px; }
+
+/* ── Sticky mobile add bar ────────────────── */
+.mobile-add-bar {
+ display: none;
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 90;
+ padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
+ background: rgba(255,255,255,.92);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border-top: 1px solid var(--border);
+ align-items: center; gap: 14px;
+}
+.mobile-add-price { font-size: 17px; font-weight: 600; white-space: nowrap; }
+.mobile-add-btn {
+ flex: 1; padding: 13px;
+ background: var(--text); color: #fff;
+ border: none; border-radius: 12px;
+ font-size: 16px; font-weight: 500; cursor: pointer;
+ font-family: inherit; transition: opacity .15s;
+}
+.mobile-add-btn:hover { opacity: .8; }
+.mobile-add-btn:disabled { opacity: .35; cursor: not-allowed; }
+
+/* ── Responsive ───────────────────────────── */
+@media (max-width: 900px) {
+ .product { grid-template-columns: 1fr; gap: 36px; }
+ .product-info { position: static; }
+ .checkout-grid { grid-template-columns: 1fr; }
+ .checkout-summary-col { position: static; order: -1; }
+}
+
+
+@media (max-width: 768px) {
+ /* Bigger touch targets */
+ .qty-btn { width: 38px; height: 38px; font-size: 20px; }
+ .quick-size-btn { padding: 8px 12px; font-size: 13px; min-height: 36px; }
+ .opt-select { padding: 13px 36px 13px 14px; font-size: 16px; } /* 16px prevents iOS zoom */
+ .field input, .field select { font-size: 16px; } /* same reason */
+
+ /* Show sticky add bar on product page */
+ .mobile-add-bar { display: flex; }
+ /* Add bottom padding so the sticky bar doesn't cover page content */
+ .product { padding-bottom: 90px; }
+}
+
+@media (max-width: 640px) {
+ .shop { padding: 28px 16px; }
+ .shop-header { margin-bottom: 24px; }
+ .shop-header h1 { font-size: 28px; }
+ .shop-header p { font-size: 14px; }
+ .page-title { font-size: 28px; }
+ .form-row { grid-template-columns: 1fr; }
+ .cart-item { grid-template-columns: 64px 1fr; }
+ .item-controls { grid-column: 1 / -1; flex-direction: row; justify-content: space-between; }
+ .cart-page { padding: 36px 16px; }
+ .checkout-grid { padding: 36px 16px; }
+ .success { padding: 60px 20px; }
+
+ /* Single-column product grid */
+ .grid { grid-template-columns: 1fr; gap: 12px; }
+}
+`;
--- /dev/null
+import type { FC } from "hono/jsx";
+import type { PrintifyProduct } from "../types.ts";
+
+interface Props {
+ product: PrintifyProduct;
+}
+
+interface QuickVariant {
+ id: number;
+ label: string;
+}
+
+export function formatPrice(cents: number): string {
+ return `$${(cents / 100).toFixed(2)}`;
+}
+
+export function defaultImage(product: PrintifyProduct): string {
+ const def = product.images.find((i) => i.is_default) ?? product.images[0];
+ return def?.src ?? "";
+}
+
+export function minPrice(product: PrintifyProduct): number {
+ const enabled = product.variants.filter((v) => v.is_enabled && v.is_available);
+ if (!enabled.length) return 0;
+ return Math.min(...enabled.map((v) => v.price));
+}
+
+function enabledColors(product: PrintifyProduct) {
+ const colorOpt = product.options.find((o) =>
+ o.name.toLowerCase().includes("color")
+ );
+ if (!colorOpt) return [];
+ const enabledIds = new Set(
+ product.variants
+ .filter((v) => v.is_enabled && v.is_available)
+ .flatMap((v) => v.options)
+ );
+ return colorOpt.values.filter((v) => enabledIds.has(v.id));
+}
+
+function colorToCSS(name: string): string {
+ const n = name.toLowerCase().trim();
+ const exact: Record<string, string> = {
+ "black": "#1c1c1e", "white": "#ffffff", "navy": "#1c2f5e",
+ "navy blue": "#1c2f5e", "red": "#c0392b", "royal": "#2255bb",
+ "royal blue": "#2255bb", "forest green": "#2d6a2d", "forest": "#2d6a2d",
+ "maroon": "#6b1a1a", "sport grey": "#9e9e9e", "sport gray": "#9e9e9e",
+ "dark heather": "#4a4a4a", "heather": "#8a8a9a", "ash": "#cccccc",
+ "charcoal": "#3d3d3d", "military green": "#4a5240", "purple": "#6a1b9a",
+ "gold": "#d4a017", "orange": "#e65c00", "carolina blue": "#5b9bd5",
+ "light blue": "#7ec8e3", "kelly green": "#4caf50", "sapphire": "#0f52ba",
+ "brown": "#795548", "natural": "#f5f0e0", "sand": "#c2b280",
+ "pink": "#f06292", "hot pink": "#e91e8c", "yellow": "#f9d71c",
+ "lime": "#c6e03a", "indigo": "#3949ab", "teal": "#00796b",
+ "olive": "#827717", "cream": "#fffdd0", "midnight": "#191970",
+ "slate": "#607d8b", "cardinal": "#9b1c31", "burgundy": "#800020",
+ "coral": "#ff6b6b", "violet": "#7b1fa2", "graphite": "#555555",
+ "stone": "#a89880", "smoke": "#d0cece", "heather navy": "#2c3e6b",
+ "heather red": "#b05050", "heather grey": "#9a9aaa", "heather gray": "#9a9aaa",
+ };
+ if (exact[n]) return exact[n];
+ for (const [key, val] of Object.entries(exact)) {
+ if (n.includes(key) || key.includes(n)) return val;
+ }
+ return "#d2d2d7";
+}
+
+// Returns size variants for the default color, used in the quick-add panel
+function quickAddVariants(product: PrintifyProduct): QuickVariant[] {
+ const enabled = product.variants.filter((v) => v.is_enabled && v.is_available);
+ if (!enabled.length) return [];
+
+ const heroImg = product.images.find((i) => i.is_default) ?? product.images[0];
+ const defaultVariant =
+ enabled.find((v) => heroImg?.variant_ids.includes(v.id)) ?? enabled[0];
+
+ const sizeOpt = product.options.find((o) => o.name.toLowerCase().includes("size"));
+
+ // No size option — single button
+ if (!sizeOpt) {
+ return [{ id: defaultVariant.id, label: "Add to Bag" }];
+ }
+
+ // Filter to variants sharing the same non-size options as the default variant
+ const sizeValueIds = new Set(sizeOpt.values.map((v) => v.id));
+ const defaultColorOptionIds = defaultVariant.options.filter(
+ (id) => !sizeValueIds.has(id)
+ );
+
+ const sameColor = enabled.filter((v) =>
+ defaultColorOptionIds.every((id) => v.options.includes(id))
+ );
+
+ return sameColor.map((v) => {
+ const sizeVal = sizeOpt.values.find((sv) => v.options.includes(sv.id));
+ return { id: v.id, label: sizeVal?.title ?? v.title };
+ });
+}
+
+const MAX_DOTS = 8;
+
+export const ProductCard: FC<Props> = ({ product }) => {
+ const img = defaultImage(product);
+ const price = minPrice(product);
+ const colors = enabledColors(product);
+ const shown = colors.slice(0, MAX_DOTS);
+ const overflow = colors.length - shown.length;
+ const qaVariants = quickAddVariants(product);
+ const single = qaVariants.length === 1;
+
+ return (
+ <div class="card">
+ <div class="card-img">
+ <a href={`/products/${product.id}`} class="card-img-link">
+ {img && <img src={img} alt={product.title} loading="lazy" />}
+ </a>
+
+ {qaVariants.length > 0 && (
+ <button
+ class="qa-toggle"
+ onclick="toggleQuickAdd(event,this)"
+ aria-label="Quick add"
+ >
+ +
+ </button>
+ )}
+ {qaVariants.length > 0 && (
+ <div class="quick-add" onclick="event.stopPropagation()">
+ {single ? (
+ <button
+ class="quick-single-btn"
+ data-product={product.id}
+ data-variant={qaVariants[0].id}
+ onclick="quickAdd(event,this)"
+ >
+ Add to Bag
+ </button>
+ ) : (
+ <>
+ <div class="quick-label">Quick Add</div>
+ <div class="quick-btns">
+ {qaVariants.map((v) => (
+ <button
+ key={v.id}
+ class="quick-size-btn"
+ data-product={product.id}
+ data-variant={v.id}
+ onclick="quickAdd(event,this)"
+ >
+ {v.label}
+ </button>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ )}
+ </div>
+
+ <a href={`/products/${product.id}`} class="card-body">
+ <div class="card-title">{product.title}</div>
+ <div class="card-price">
+ {price ? `From ${formatPrice(price)}` : "—"}
+ </div>
+ {colors.length > 0 && (
+ <div class="color-dots">
+ {shown.map((c) => (
+ <span
+ key={c.id}
+ class="color-dot"
+ style={`background:${colorToCSS(c.title)}`}
+ title={c.title}
+ />
+ ))}
+ {overflow > 0 && (
+ <span class="color-dot-more">+{overflow}</span>
+ )}
+ </div>
+ )}
+ </a>
+ </div>
+ );
+};
--- /dev/null
+export const DRY_RUN = Deno.args.includes("--dry-run");
--- /dev/null
+import type { Cart, CartItem, ShippingAddress } from "../types.ts";
+
+const kv = await Deno.openKv();
+const TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+export async function getCart(sid: string): Promise<Cart> {
+ const r = await kv.get<Cart>(["cart", sid]);
+ return r.value ?? { items: [] };
+}
+
+export async function addToCart(sid: string, item: CartItem): Promise<Cart> {
+ const cart = await getCart(sid);
+ const existing = cart.items.find((i) => i.variantId === item.variantId);
+ if (existing) {
+ existing.quantity += item.quantity;
+ } else {
+ cart.items.push(item);
+ }
+ await kv.set(["cart", sid], cart, { expireIn: TTL });
+ return cart;
+}
+
+export async function updateCartItem(
+ sid: string,
+ variantId: number,
+ quantity: number
+): Promise<Cart> {
+ const cart = await getCart(sid);
+ if (quantity <= 0) {
+ cart.items = cart.items.filter((i) => i.variantId !== variantId);
+ } else {
+ const item = cart.items.find((i) => i.variantId === variantId);
+ if (item) item.quantity = quantity;
+ }
+ await kv.set(["cart", sid], cart, { expireIn: TTL });
+ return cart;
+}
+
+export async function clearCart(sid: string): Promise<void> {
+ await kv.delete(["cart", sid]);
+}
+
+export async function saveShipping(sid: string, shipping: ShippingAddress): Promise<void> {
+ await kv.set(["shipping", sid], shipping, { expireIn: 60 * 60 * 1000 });
+}
+
+export async function getShipping(sid: string): Promise<ShippingAddress | null> {
+ const r = await kv.get<ShippingAddress>(["shipping", sid]);
+ return r.value;
+}
+
+export async function deleteShipping(sid: string): Promise<void> {
+ await kv.delete(["shipping", sid]);
+}
--- /dev/null
+import { createMiddleware } from "hono/factory";
+import { getCookie, setCookie } from "hono/cookie";
+
+declare module "hono" {
+ interface ContextVariableMap {
+ sessionId: string;
+ }
+}
+
+export const session = createMiddleware(async (c, next) => {
+ let sid = getCookie(c, "sid");
+ if (!sid) {
+ sid = crypto.randomUUID();
+ setCookie(c, "sid", sid, {
+ httpOnly: true,
+ sameSite: "Lax",
+ maxAge: 7 * 24 * 60 * 60,
+ path: "/",
+ });
+ }
+ c.set("sessionId", sid);
+ await next();
+});
--- /dev/null
+import { Hono } from "hono";
+import { Layout } from "../components/Layout.tsx";
+import { formatPrice } from "../components/ProductCard.tsx";
+import { getCart, addToCart, updateCartItem } from "../kv/cart.ts";
+import { getPrintify } from "../client/printify.ts";
+import type { CartItem } from "../types.ts";
+
+export const cartRoute = new Hono();
+
+// View cart
+cartRoute.get("/", async (c) => {
+ const sid = c.get("sessionId");
+ const cart = await getCart(sid);
+ const total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0);
+ const cartCount = cart.items.reduce((s, i) => s + i.quantity, 0);
+
+ return c.html(
+ <Layout title="Bag" cartCount={cartCount}>
+ <div class="cart-page">
+ <h1 class="page-title">Your Bag</h1>
+ {cart.items.length === 0 ? (
+ <div class="cart-empty">
+ <p>Your bag is empty.</p>
+ <a href="/" class="btn-primary">
+ Continue Shopping
+ </a>
+ </div>
+ ) : (
+ <>
+ <div class="cart-items">
+ {cart.items.map((item) => (
+ <CartRow key={item.variantId} item={item} />
+ ))}
+ </div>
+ <div class="cart-footer">
+ <div class="cart-total">
+ <small>Total</small>
+ {formatPrice(total)}
+ </div>
+ <a href="/checkout" class="btn-primary">
+ Checkout
+ </a>
+ </div>
+ </>
+ )}
+ </div>
+ </Layout>
+ );
+});
+
+// Add item — fetches real price from Printify so form data can't be tampered
+cartRoute.post("/add", async (c) => {
+ const sid = c.get("sessionId");
+ const body = await c.req.parseBody();
+ const productId = String(body.productId);
+ const variantId = Number(body.variantId);
+
+ if (!variantId || !productId) return c.redirect("/cart");
+
+ let product;
+ try {
+ product = await getPrintify().getProduct(productId);
+ } catch {
+ return c.redirect("/cart");
+ }
+
+ const variant = product.variants.find((v) => v.id === variantId);
+ if (!variant || !variant.is_available) return c.redirect("/cart");
+
+ const image =
+ product.images.find((i) => i.variant_ids.includes(variantId))?.src ??
+ product.images.find((i) => i.is_default)?.src ??
+ product.images[0]?.src ??
+ "";
+
+ const item: CartItem = {
+ productId,
+ variantId,
+ title: product.title,
+ variantTitle: variant.title,
+ price: variant.price,
+ image,
+ quantity: 1,
+ };
+
+ await addToCart(sid, item);
+ return c.redirect("/cart");
+});
+
+// Add item via AJAX (quick-add from grid) — returns JSON with new cart count
+cartRoute.post("/add-ajax", async (c) => {
+ const sid = c.get("sessionId");
+ const body = await c.req.parseBody();
+ const productId = String(body.productId);
+ const variantId = Number(body.variantId);
+
+ if (!variantId || !productId) return c.json({ error: "invalid" }, 400);
+
+ let product;
+ try {
+ product = await getPrintify().getProduct(productId);
+ } catch {
+ return c.json({ error: "not_found" }, 404);
+ }
+
+ const variant = product.variants.find((v) => v.id === variantId);
+ if (!variant || !variant.is_available) return c.json({ error: "unavailable" }, 400);
+
+ const image =
+ product.images.find((i) => i.variant_ids.includes(variantId))?.src ??
+ product.images.find((i) => i.is_default)?.src ??
+ product.images[0]?.src ?? "";
+
+ const cart = await addToCart(sid, {
+ productId,
+ variantId,
+ title: product.title,
+ variantTitle: variant.title,
+ price: variant.price,
+ image,
+ quantity: 1,
+ });
+
+ const count = cart.items.reduce((s, i) => s + i.quantity, 0);
+ return c.json({ count });
+});
+
+// Update quantity
+cartRoute.post("/update", async (c) => {
+ const sid = c.get("sessionId");
+ const body = await c.req.parseBody();
+ const variantId = Number(body.variantId);
+ const quantity = Number(body.quantity);
+ await updateCartItem(sid, variantId, quantity);
+ return c.redirect("/cart");
+});
+
+// Remove item
+cartRoute.post("/remove", async (c) => {
+ const sid = c.get("sessionId");
+ const body = await c.req.parseBody();
+ const variantId = Number(body.variantId);
+ await updateCartItem(sid, variantId, 0);
+ return c.redirect("/cart");
+});
+
+interface CartRowProps {
+ item: CartItem;
+}
+
+const CartRow = ({ item }: CartRowProps) => (
+ <div class="cart-item">
+ <div class="cart-item-img">
+ {item.image && <img src={item.image} alt={item.title} />}
+ </div>
+ <div>
+ <div class="item-title">{item.title}</div>
+ {item.variantTitle && (
+ <div class="item-variant">{item.variantTitle}</div>
+ )}
+ <div class="item-price">{formatPrice(item.price)} each</div>
+ </div>
+ <div class="item-controls">
+ <div class="qty">
+ <form method="post" action="/cart/update" style="display:contents">
+ <input type="hidden" name="variantId" value={item.variantId} />
+ <input
+ type="hidden"
+ name="quantity"
+ value={Math.max(0, item.quantity - 1)}
+ />
+ <button type="submit" class="qty-btn" aria-label="Decrease">
+ −
+ </button>
+ </form>
+ <span class="qty-val">{item.quantity}</span>
+ <form method="post" action="/cart/update" style="display:contents">
+ <input type="hidden" name="variantId" value={item.variantId} />
+ <input type="hidden" name="quantity" value={item.quantity + 1} />
+ <button type="submit" class="qty-btn" aria-label="Increase">
+ +
+ </button>
+ </form>
+ </div>
+ <form method="post" action="/cart/remove">
+ <input type="hidden" name="variantId" value={item.variantId} />
+ <button type="submit" class="remove-btn">
+ Remove
+ </button>
+ </form>
+ </div>
+ </div>
+);
--- /dev/null
+import { Hono } from "hono";
+import { Layout } from "../components/Layout.tsx";
+import { formatPrice } from "../components/ProductCard.tsx";
+import {
+ getCart,
+ clearCart,
+ saveShipping,
+ getShipping,
+ deleteShipping,
+} from "../kv/cart.ts";
+import { getPrintify } from "../client/printify.ts";
+import { DRY_RUN } from "../config.ts";
+import type { ShippingAddress } from "../types.ts";
+
+export const checkoutRoute = new Hono();
+
+// ── Stripe helpers (plain fetch, no SDK needed) ──────────────────────────────
+
+function stripeKey() {
+ return Deno.env.get("STRIPE_SECRET_KEY") ?? "";
+}
+
+async function stripePost<T>(path: string, params: Record<string, string>): Promise<T> {
+ const res = await fetch(`https://api.stripe.com/v1${path}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${stripeKey()}`,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams(params).toString(),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error?.message ?? `Stripe ${res.status}`);
+ return data as T;
+}
+
+async function stripeGet<T>(path: string): Promise<T> {
+ const res = await fetch(`https://api.stripe.com/v1${path}`, {
+ headers: { Authorization: `Bearer ${stripeKey()}` },
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error?.message ?? `Stripe ${res.status}`);
+ return data as T;
+}
+
+// ── GET /checkout ────────────────────────────────────────────────────────────
+
+checkoutRoute.get("/", async (c) => {
+ const sid = c.get("sessionId");
+ const cart = await getCart(sid);
+ if (!cart.items.length) return c.redirect("/cart");
+
+ const total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0);
+ const cartCount = cart.items.reduce((s, i) => s + i.quantity, 0);
+ const stripePk = Deno.env.get("STRIPE_PUBLIC_KEY") ?? "";
+
+ return c.html(
+ <Layout title="Checkout" cartCount={cartCount}>
+ {DRY_RUN && (
+ <div class="dry-run-banner">
+ Dry-run — Stripe and Printify are skipped, payload logged to console
+ </div>
+ )}
+ <div class="checkout-grid">
+ {/* ── Left: form ── */}
+ <div class="checkout-form-col">
+ <div class="section-title" style="margin-bottom:20px">Shipping</div>
+ <form id="checkout-form" novalidate>
+ <div class="form-row">
+ <div class="field">
+ <label>First name</label>
+ <input name="firstName" required autocomplete="given-name" />
+ </div>
+ <div class="field">
+ <label>Last name</label>
+ <input name="lastName" required autocomplete="family-name" />
+ </div>
+ </div>
+ <div class="field">
+ <label>Email</label>
+ <input name="email" type="email" required autocomplete="email" />
+ </div>
+ <div class="field">
+ <label>Phone</label>
+ <input name="phone" type="tel" autocomplete="tel" />
+ </div>
+ <div class="field">
+ <label>Address</label>
+ <input name="address1" required autocomplete="address-line1" placeholder="Street address" />
+ </div>
+ <div class="field">
+ <input name="address2" autocomplete="address-line2" placeholder="Apt, suite, etc. (optional)" />
+ </div>
+ <div class="form-row">
+ <div class="field">
+ <label>City</label>
+ <input name="city" required autocomplete="address-level2" />
+ </div>
+ <div class="field">
+ <label>State / Province</label>
+ <input name="region" required autocomplete="address-level1" />
+ </div>
+ </div>
+ <div class="form-row">
+ <div class="field">
+ <label>ZIP / Postal</label>
+ <input name="zip" required autocomplete="postal-code" />
+ </div>
+ <div class="field">
+ <label>Country</label>
+ <select name="country" required autocomplete="country">
+ <option value="US">United States</option>
+ <option value="CA">Canada</option>
+ <option value="GB">United Kingdom</option>
+ <option value="AU">Australia</option>
+ <option value="DE">Germany</option>
+ <option value="FR">France</option>
+ </select>
+ </div>
+ </div>
+
+ <div style="margin-top:36px">
+ <div class="section-title" style="margin-bottom:16px">Payment</div>
+ {DRY_RUN
+ ? <p class="notice">Stripe skipped in dry-run mode.</p>
+ : <div id="payment-element" />
+ }
+ <p id="payment-message" />
+ <button
+ type="submit"
+ class="pay-btn"
+ id="pay-btn"
+ style="margin-top:20px"
+ disabled={!DRY_RUN}
+ >
+ {DRY_RUN
+ ? `Place Order (dry run) — ${formatPrice(total)}`
+ : `Pay ${formatPrice(total)}`}
+ </button>
+ </div>
+ </form>
+ </div>
+
+ {/* ── Right: summary ── */}
+ <div class="checkout-summary-col">
+ <div class="section-title">Order Summary</div>
+ <div class="order-summary">
+ <div class="summary-items">
+ {cart.items.map((item) => (
+ <div class="summary-row" key={item.variantId}>
+ <span class="summary-row-name">{item.title}</span>
+ <span class="summary-row-qty">×{item.quantity}</span>
+ <span class="summary-row-price">
+ {formatPrice(item.price * item.quantity)}
+ </span>
+ </div>
+ ))}
+ </div>
+ <hr class="summary-divider" />
+ <div class="summary-total">
+ <span>Total</span>
+ <span>{formatPrice(total)}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {!DRY_RUN && <script src="https://js.stripe.com/v3/" />}
+ <script
+ dangerouslySetInnerHTML={{ __html: checkoutJs(stripePk, total) }}
+ />
+ </Layout>
+ );
+});
+
+// ── POST /checkout/intent — create Stripe payment intent ─────────────────────
+
+checkoutRoute.post("/intent", async (c) => {
+ const sid = c.get("sessionId");
+ const cart = await getCart(sid);
+ if (!cart.items.length) return c.json({ error: "Cart is empty" }, 400);
+
+ const total = cart.items.reduce((s, i) => s + i.price * i.quantity, 0);
+ try {
+ const intent = await stripePost<{ client_secret: string }>("/payment_intents", {
+ amount: String(total),
+ currency: "usd",
+ "automatic_payment_methods[enabled]": "true",
+ });
+ return c.json({ clientSecret: intent.client_secret });
+ } catch (err) {
+ return c.json({ error: (err as Error).message }, 500);
+ }
+});
+
+// ── POST /checkout/shipping — persist shipping before Stripe redirect ─────────
+
+checkoutRoute.post("/shipping", async (c) => {
+ const sid = c.get("sessionId");
+ const body = await c.req.json<ShippingAddress>();
+ await saveShipping(sid, body);
+ return c.json({ ok: true });
+});
+
+// ── POST /checkout/dry-run — dry-run submit (no Stripe, no Printify) ─────────
+
+checkoutRoute.post("/dry-run", async (c) => {
+ if (!DRY_RUN) return c.json({ error: "Not in dry-run mode" }, 403);
+
+ const sid = c.get("sessionId");
+ const cart = await getCart(sid);
+ const body = await c.req.json<ShippingAddress>();
+
+ const payload = {
+ external_id: crypto.randomUUID(),
+ send_shipping_notification: true,
+ shipping_method: 1,
+ address_to: {
+ first_name: body.firstName,
+ last_name: body.lastName,
+ email: body.email,
+ phone: body.phone,
+ country: body.country,
+ region: body.region,
+ address1: body.address1,
+ address2: body.address2,
+ city: body.city,
+ zip: body.zip,
+ },
+ line_items: cart.items.map((i) => ({
+ product_id: i.productId,
+ variant_id: i.variantId,
+ quantity: i.quantity,
+ title: i.title,
+ variant_title: i.variantTitle,
+ price: i.price,
+ })),
+ };
+
+ console.log("\n┌─────────────────────────────────────┐");
+ console.log("│ DRY RUN — Printify order payload │");
+ console.log("└─────────────────────────────────────┘");
+ console.log(JSON.stringify(payload, null, 2));
+ console.log("");
+
+ await clearCart(sid);
+ return c.json({ redirect: "/checkout/success?dry=1" });
+});
+
+// ── GET /checkout/done — Stripe redirects here after payment ─────────────────
+
+checkoutRoute.get("/done", async (c) => {
+ const sid = c.get("sessionId");
+ const intentId = c.req.query("payment_intent");
+
+ if (!intentId) return c.redirect("/checkout");
+
+ // Verify payment succeeded
+ let status: string;
+ try {
+ const intent = await stripeGet<{ status: string }>(`/payment_intents/${intentId}`);
+ status = intent.status;
+ } catch {
+ return c.redirect("/checkout?error=verification_failed");
+ }
+
+ if (status !== "succeeded") {
+ return c.redirect(`/checkout?error=payment_${status}`);
+ }
+
+ // Retrieve shipping saved before the Stripe redirect
+ const shipping = await getShipping(sid);
+ if (!shipping) return c.redirect("/checkout?error=session_expired");
+
+ const cart = await getCart(sid);
+ if (!cart.items.length) return c.redirect("/");
+
+ // Submit to Printify — Printify auto-charges your card on file
+ let orderId = "";
+ try {
+ const order = await getPrintify().createOrder({
+ externalId: intentId,
+ items: cart.items,
+ shipping,
+ });
+ orderId = order.id;
+ } catch (err) {
+ // Payment taken but Printify failed — log for manual recovery
+ console.error(`PRINTIFY ORDER FAILED (payment ${intentId}):`, err);
+ console.error("Cart:", JSON.stringify(cart.items));
+ console.error("Shipping:", JSON.stringify(shipping));
+ }
+
+ await Promise.all([clearCart(sid), deleteShipping(sid)]);
+ return c.redirect(`/checkout/success?order=${encodeURIComponent(orderId)}`);
+});
+
+// ── GET /checkout/success ────────────────────────────────────────────────────
+
+checkoutRoute.get("/success", (c) => {
+ const orderId = c.req.query("order") ?? "";
+ const isDry = c.req.query("dry") === "1";
+
+ return c.html(
+ <Layout title="Order Confirmed">
+ {isDry && (
+ <div class="dry-run-banner">
+ Dry-run — order logged to console, nothing submitted
+ </div>
+ )}
+ <div class="success">
+ <div class="success-icon">✓</div>
+ <h1>Order confirmed</h1>
+ <p>
+ {isDry
+ ? "Check your terminal for the full order payload."
+ : "Your order is being prepared. You'll receive a shipping confirmation by email once it's on its way."}
+ </p>
+ {orderId && !isDry && <div class="order-id">Order {orderId}</div>}
+ <a href="/" class="btn-primary">Continue Shopping</a>
+ </div>
+ </Layout>
+ );
+});
+
+// ── Client-side JS ───────────────────────────────────────────────────────────
+
+function checkoutJs(stripePk: string, total: number): string {
+ const isDry = DRY_RUN;
+ return `
+(async function () {
+ ${isDry ? dryRunJs() : stripeJs(stripePk, total)}
+})();
+`;
+}
+
+function stripeJs(pk: string, total: number): string {
+ return `
+ const stripe = Stripe(${JSON.stringify(pk)});
+ const form = document.getElementById('checkout-form');
+ const btn = document.getElementById('pay-btn');
+ const msg = document.getElementById('payment-message');
+
+ // Create payment intent on load
+ const res = await fetch('/checkout/intent', { method: 'POST' });
+ if (!res.ok) {
+ msg.textContent = 'Could not initialise payment. Please refresh.';
+ return;
+ }
+ const { clientSecret, error: intentErr } = await res.json();
+ if (intentErr) { msg.textContent = intentErr; return; }
+
+ const elements = stripe.elements({ clientSecret });
+ const paymentEl = elements.create('payment');
+ paymentEl.mount('#payment-element');
+ paymentEl.on('ready', () => { btn.disabled = false; });
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ btn.disabled = true;
+ btn.textContent = 'Processing…';
+ msg.textContent = '';
+
+ // Persist shipping before Stripe redirects away
+ const shipping = {
+ firstName: form.firstName.value,
+ lastName: form.lastName.value,
+ email: form.email.value,
+ phone: form.phone.value,
+ address1: form.address1.value,
+ address2: form.address2.value,
+ city: form.city.value,
+ region: form.region.value,
+ country: form.country.value,
+ zip: form.zip.value,
+ };
+ await fetch('/checkout/shipping', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(shipping),
+ });
+
+ const { error } = await stripe.confirmPayment({
+ elements,
+ confirmParams: { return_url: window.location.origin + '/checkout/done' },
+ });
+
+ if (error) {
+ msg.textContent = error.message;
+ btn.disabled = false;
+ btn.textContent = 'Pay ${formatPrice(total)}';
+ }
+ });
+`;
+}
+
+function dryRunJs(): string {
+ return `
+ const form = document.getElementById('checkout-form');
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const btn = document.getElementById('pay-btn');
+ btn.disabled = true;
+ btn.textContent = 'Submitting…';
+ const shipping = {
+ firstName: form.firstName.value,
+ lastName: form.lastName.value,
+ email: form.email.value,
+ phone: form.phone.value,
+ address1: form.address1.value,
+ address2: form.address2.value,
+ city: form.city.value,
+ region: form.region.value,
+ country: form.country.value,
+ zip: form.zip.value,
+ };
+ const res = await fetch('/checkout/dry-run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(shipping),
+ });
+ const { redirect } = await res.json();
+ window.location.href = redirect;
+ });
+`;
+}
--- /dev/null
+import { Hono } from "hono";
+import { Layout } from "../components/Layout.tsx";
+import { formatPrice, defaultImage } from "../components/ProductCard.tsx";
+import { getPrintify } from "../client/printify.ts";
+import { getCart } from "../kv/cart.ts";
+import type { PrintifyProduct, PrintifyOption } from "../types.ts";
+
+export const productRoute = new Hono();
+
+productRoute.get("/:id", async (c) => {
+ const sid = c.get("sessionId");
+ const [product, cart] = await Promise.all([
+ getPrintify().getProduct(c.req.param("id")).catch(() => null),
+ getCart(sid),
+ ]);
+
+ if (!product) {
+ return c.html(
+ <Layout cartCount={0}>
+ <div class="error-page">
+ <h1>Product not found</h1>
+ <p><a href="/">← Back to shop</a></p>
+ </div>
+ </Layout>,
+ 404
+ );
+ }
+
+ const cartCount = cart.items.reduce((s, i) => s + i.quantity, 0);
+ const enabledVariants = product.variants.filter(
+ (v) => v.is_enabled && v.is_available
+ );
+
+ // Only show option values that appear in at least one enabled variant
+ const enabledValueIds = new Set(enabledVariants.flatMap((v) => v.options));
+ const filteredOptions: PrintifyOption[] = product.options
+ .map((opt) => ({
+ ...opt,
+ values: opt.values.filter((val) => enabledValueIds.has(val.id)),
+ }))
+ .filter((opt) => opt.values.length > 0);
+
+ const images = product.images.slice(0, 6);
+ const tagline = Deno.env.get("STORE_TAGLINE") ?? "Quality design, made to order.";
+ const fullDesc = product.description ? stripHtml(product.description) : "";
+
+ // Map each variant ID → the src of its associated image
+ const variantImages: Record<number, string> = {};
+ for (const variant of enabledVariants) {
+ const img =
+ product.images.find((i) => i.variant_ids.includes(variant.id)) ??
+ product.images.find((i) => i.is_default);
+ if (img) variantImages[variant.id] = img.src;
+ }
+
+ // Find which enabled variant the default (hero) image belongs to,
+ // so the dropdowns and image are in sync from the very first render.
+ const heroImage = product.images.find((i) => i.is_default) ?? product.images[0];
+ const defaultVariant =
+ enabledVariants.find((v) => heroImage?.variant_ids.includes(v.id)) ??
+ enabledVariants[0];
+ const defaultImg = heroImage?.src ?? "";
+
+ return c.html(
+ <Layout title={product.title} cartCount={cartCount}>
+ <div class="product">
+ <ProductImages images={images} defaultImg={defaultImg} />
+
+ <div class="product-info">
+ <h1 class="product-title">{product.title}</h1>
+ <p class="product-price" id="price">
+ {defaultVariant
+ ? formatPrice(defaultVariant.price)
+ : "Unavailable"}
+ </p>
+
+ <p class="product-tagline">{tagline}</p>
+
+ {fullDesc && (
+ <details class="product-desc-details">
+ <summary class="see-more">See more</summary>
+ <p class="product-desc-full">{fullDesc}</p>
+ </details>
+ )}
+
+ {filteredOptions.length > 0 && (
+ <div class="options">
+ {filteredOptions.map((opt) => (
+ <div key={opt.name}>
+ <div class="option-label">{opt.name}</div>
+ <select
+ class="opt-select"
+ data-option={opt.name}
+ id={`opt-${opt.name}`}
+ >
+ {opt.values.map((val) => (
+ <option
+ key={val.id}
+ value={val.id}
+ selected={defaultVariant?.options.includes(val.id)}
+ >
+ {val.title}
+ </option>
+ ))}
+ </select>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <form method="post" action="/cart/add" id="add-form">
+ <input type="hidden" name="productId" value={product.id} />
+ <input type="hidden" name="variantId" id="variant-id" value={defaultVariant?.id ?? ""} />
+ <button
+ type="submit"
+ class="add-btn"
+ id="add-btn"
+ disabled={!defaultVariant}
+ >
+ {enabledVariants.length ? "Add to Bag" : "Unavailable"}
+ </button>
+ </form>
+ </div>
+ </div>
+
+ {/* Mobile sticky add bar — hidden on desktop via CSS */}
+ <div class="mobile-add-bar">
+ <span class="mobile-add-price" id="mobile-price">
+ {defaultVariant ? formatPrice(defaultVariant.price) : ""}
+ </span>
+ <button
+ type="submit"
+ form="add-form"
+ class="mobile-add-btn"
+ id="mobile-add-btn"
+ disabled={!defaultVariant}
+ >
+ Add to Bag
+ </button>
+ </div>
+
+ <script
+ id="variants-data"
+ type="application/json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(enabledVariants) }}
+ />
+ <script
+ id="variant-images-data"
+ type="application/json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(variantImages) }}
+ />
+ <script dangerouslySetInnerHTML={{ __html: productJs }} />
+ </Layout>
+ );
+});
+
+const ProductImages = ({
+ images,
+ defaultImg,
+}: {
+ images: PrintifyProduct["images"];
+ defaultImg: string;
+}) => (
+ <div class="product-images">
+ <div class="main-img">
+ <img src={defaultImg} alt="" id="main-img-el" />
+ </div>
+ {images.length > 1 && (
+ <div class="thumbs">
+ {images.map((img, i) => (
+ <div
+ key={img.src}
+ class={`thumb${i === 0 ? " active" : ""}`}
+ data-src={img.src}
+ onclick="selectThumb(this)"
+ >
+ <img src={img.src} alt="" loading="lazy" />
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+);
+
+function stripHtml(html: string): string {
+ return html
+ .replace(/<[^>]*>/g, " ")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/ /g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+const productJs = `
+(function () {
+ const variants = JSON.parse(document.getElementById('variants-data').textContent);
+ const variantImages = JSON.parse(document.getElementById('variant-images-data').textContent);
+ const selected = {};
+
+ function fmt(cents) { return '$' + (cents / 100).toFixed(2); }
+
+ function findVariant() {
+ const keys = Object.keys(selected);
+ if (!keys.length) return variants[0] || null;
+ return variants.find(v =>
+ keys.every(opt => v.options.includes(Number(selected[opt])))
+ ) || null;
+ }
+
+ function setMainImage(src) {
+ if (!src) return;
+ const mainEl = document.getElementById('main-img-el');
+ if (mainEl.src === src) return;
+ mainEl.src = src;
+ // Sync the active thumbnail
+ document.querySelectorAll('.thumb').forEach(t => {
+ t.classList.toggle('active', t.dataset.src === src);
+ });
+ }
+
+ function updateUI() {
+ const v = findVariant();
+ const priceEl = document.getElementById('price');
+ const variantInput = document.getElementById('variant-id');
+ const addBtn = document.getElementById('add-btn');
+ const mobilePrice = document.getElementById('mobile-price');
+ const mobileBtn = document.getElementById('mobile-add-btn');
+
+ if (v && v.is_available) {
+ const label = fmt(v.price);
+ priceEl.textContent = label;
+ if (mobilePrice) mobilePrice.textContent = label;
+ variantInput.value = v.id;
+ addBtn.disabled = false;
+ addBtn.textContent = 'Add to Bag';
+ if (mobileBtn) { mobileBtn.disabled = false; mobileBtn.textContent = 'Add to Bag'; }
+ } else {
+ variantInput.value = '';
+ const label = v ? 'Unavailable' : 'Select options';
+ addBtn.disabled = true;
+ addBtn.textContent = label;
+ if (mobileBtn) { mobileBtn.disabled = true; mobileBtn.textContent = label; }
+ }
+
+ if (v && variantImages[v.id]) {
+ setMainImage(variantImages[v.id]);
+ }
+ }
+
+ document.querySelectorAll('.opt-select').forEach(sel => {
+ selected[sel.dataset.option] = sel.value;
+ sel.addEventListener('change', function () {
+ selected[this.dataset.option] = this.value;
+ updateUI();
+ });
+ });
+
+ updateUI();
+})();
+
+function selectThumb(el) {
+ document.querySelectorAll('.thumb').forEach(t => t.classList.remove('active'));
+ el.classList.add('active');
+ document.getElementById('main-img-el').src = el.dataset.src;
+}
+`;
--- /dev/null
+import { Hono } from "hono";
+import { Layout } from "../components/Layout.tsx";
+import { ProductCard } from "../components/ProductCard.tsx";
+import { getPrintify } from "../client/printify.ts";
+import { getCart } from "../kv/cart.ts";
+
+export const shopRoute = new Hono();
+
+shopRoute.get("/", async (c) => {
+ const sid = c.get("sessionId");
+ const [productsRes, cart] = await Promise.all([
+ getPrintify().getProducts(1, 40),
+ getCart(sid),
+ ]);
+
+ const products = productsRes.data.filter((p) => p.visible);
+ const storeName = Deno.env.get("STORE_NAME") ?? "Store";
+
+ return c.html(
+ <Layout cartCount={cart.items.reduce((s, i) => s + i.quantity, 0)}>
+ <div class="shop">
+ <div class="shop-header">
+ <h1>{storeName}</h1>
+ <p>{products.length} product{products.length !== 1 ? "s" : ""}</p>
+ </div>
+ <div class="grid">
+ {products.map((p) => (
+ <ProductCard key={p.id} product={p} />
+ ))}
+ </div>
+ {products.length === 0 && (
+ <div class="cart-empty">
+ <p>No products published yet.</p>
+ </div>
+ )}
+ </div>
+ </Layout>
+ );
+});
--- /dev/null
+export interface PrintifyImage {
+ src: string;
+ is_default: boolean;
+ variant_ids: number[];
+}
+
+export interface PrintifyOptionValue {
+ id: number;
+ title: string;
+}
+
+export interface PrintifyOption {
+ name: string;
+ type: string;
+ values: PrintifyOptionValue[];
+}
+
+export interface PrintifyVariant {
+ id: number;
+ sku: string;
+ title: string;
+ price: number; // cents
+ is_enabled: boolean;
+ is_available: boolean;
+ options: number[]; // option value IDs
+}
+
+export interface PrintifyProduct {
+ id: string;
+ title: string;
+ description: string;
+ tags: string[];
+ options: PrintifyOption[];
+ variants: PrintifyVariant[];
+ images: PrintifyImage[];
+ visible: boolean;
+}
+
+export interface PrintifyProductsResponse {
+ current_page: number;
+ data: PrintifyProduct[];
+ last_page: number;
+ per_page: number;
+ total: number;
+}
+
+export interface CartItem {
+ productId: string;
+ variantId: number;
+ title: string;
+ variantTitle: string;
+ price: number; // cents
+ image: string;
+ quantity: number;
+}
+
+export interface Cart {
+ items: CartItem[];
+}
+
+export interface ShippingAddress {
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+ address1: string;
+ address2: string;
+ city: string;
+ region: string;
+ country: string;
+ zip: string;
+}
+