From: Roberto Morado Date: Sat, 16 May 2026 03:21:45 +0000 (-0400) Subject: initial commit X-Git-Url: https://git.morado.dev/sitemap.xml?a=commitdiff_plain;h=9823e8a00f6c4cbaf451a34d3caff7daec075e74;p=shop initial commit --- 9823e8a00f6c4cbaf451a34d3caff7daec075e74 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b6e55db --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(deno check *)" + ] + } +} diff --git a/.env b/.env new file mode 100644 index 0000000..2893b01 --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +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 + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..02ae9bc --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..774edcf --- /dev/null +++ b/README.md @@ -0,0 +1,255 @@ +# 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 +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 diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f3e6d30 --- /dev/null +++ b/deno.json @@ -0,0 +1,17 @@ +{ + "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" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..70720b8 --- /dev/null +++ b/deno.lock @@ -0,0 +1,16 @@ +{ + "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" + ] + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..e1f6984 --- /dev/null +++ b/main.ts @@ -0,0 +1,32 @@ +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( + ` +

Page not found

+ ← Home + `, + 404 + ) +); + +const port = Number(Deno.env.get("PORT") ?? 8000); +console.log(`\n Store running at http://localhost:${port}\n`); + +Deno.serve({ port }, app.fetch); diff --git a/src/client/printify.ts b/src/client/printify.ts new file mode 100644 index 0000000..57b1464 --- /dev/null +++ b/src/client/printify.ts @@ -0,0 +1,103 @@ +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(path: string, init?: RequestInit): Promise { + 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; + } + + // Auto-resolves shop ID from env or falls back to the first shop on the account + private async shop(): Promise { + if (this.resolvedShopId) return this.resolvedShopId; + if (this.shopId) { + this.resolvedShopId = this.shopId; + return this.resolvedShopId; + } + const shops = await this.req("/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 { + const id = await this.shop(); + return this.req(`/shops/${id}/products.json?page=${page}&limit=${limit}`); + } + + async getProduct(productId: string): Promise { + 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; +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..f94fd58 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,513 @@ +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 = ({ title, cartCount = 0, children }) => ( + + + + + {title ? `${title} — ${storeName}` : storeName} +