Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

AC Blank: product page + Stripe checkout for AC Native laptops

Sliding scale pricing ($96–$512), USD/DKK support, hand delivery option,
and in-person tutorial tier. Replaces WebGPU stress test in blank.mjs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+951 -111
+221
plans/blank-checkout.md
··· 1 + # Plan: `blank` — AC Native Laptop Product Page & Checkout 2 + 3 + ## What is a Blank? 4 + 5 + A **Blank** is a surplus/refurbished Lenovo ThinkPad Yoga 11e (Gen 4 or Gen 5) running AC Native OS — a pared-down creative computing environment with only stable, permanent commands. Like a blank tape or blank disc, it's an empty medium waiting to be filled. 6 + 7 + **Target price:** ~$128 (covers hardware sourcing at ~$30–75/unit + AC OS flashing + shipping) 8 + 9 + --- 10 + 11 + ## Architecture 12 + 13 + ### Existing Patterns to Follow 14 + 15 + The `mug.js` and `print.js` functions establish a clean Stripe checkout pattern: 16 + 17 + ``` 18 + piece (blank.mjs) → POST /api/blank?new=true → Stripe checkout session 19 + 20 + ← redirect to Stripe hosted checkout 21 + 22 + webhook (POST /api/blank with stripe-signature) → order confirmation + email 23 + ``` 24 + 25 + **Key pattern from mug/print:** 26 + - `POST ?new=true` → creates Stripe checkout session, returns `session.url` 27 + - `POST` with `stripe-signature` header → webhook handler for `checkout.session.completed` 28 + - Uses `respond()` from `backend/http.mjs` 29 + - Uses `email()` from `backend/email.mjs` 30 + - Uses `authorize()` from `backend/authorization.mjs` (optional, for prefilling email) 31 + - Dev/prod key switching: `dev ? STRIPE_API_TEST_PRIV_KEY : STRIPE_API_PRIV_KEY` 32 + 33 + ### Differences from Mug/Print 34 + 35 + | | Mug/Print | Blank | 36 + |---|---|---| 37 + | Fulfillment | Printful POD (automated) | Manual (flash OS, ship laptop) | 38 + | Inventory | Unlimited (on-demand) | Limited stock | 39 + | Product image | Generated mockup | Static photo(s) of the laptop | 40 + | Shipping | Printful handles | We handle (USPS/UPS) | 41 + | Price | $10–$18 | ~$128 | 42 + 43 + Because fulfillment is manual, the webhook just needs to: 44 + 1. Record the order in MongoDB 45 + 2. Send confirmation email to buyer 46 + 3. Notify us (email or webhook to Slack/Discord) that an order came in 47 + 48 + --- 49 + 50 + ## Files to Create / Modify 51 + 52 + ### 1. `system/netlify/functions/blank.mjs` — API endpoint 53 + 54 + New serverless function following the mug/print pattern: 55 + 56 + ``` 57 + GET /api/blank → product info (price, stock count, description) 58 + POST /api/blank?new=true → create Stripe checkout session 59 + POST /api/blank → Stripe webhook (order confirmation) 60 + ``` 61 + 62 + **Checkout session config:** 63 + - `mode: "payment"` 64 + - `shipping_address_collection: { allowed_countries: ["US"] }` (expand later) 65 + - Product: "The Blank — AC Native Laptop" 66 + - Price: configured in code or via Stripe product ID 67 + - `metadata: { type: "blank", model: "yoga-11e-gen5" }` 68 + - Success URL: `aesthetic.computer/blank:thanks` 69 + - Cancel URL: `aesthetic.computer/blank` 70 + 71 + **Webhook handler:** 72 + - Verify signature with `STRIPE_ENDPOINT_BLANK_SECRET` 73 + - Store order in MongoDB `orders` collection (or `blank-orders`) 74 + - Send buyer confirmation email 75 + - Send internal notification email to us 76 + 77 + ### 2. `system/public/aesthetic.computer/disks/blank.mjs` — The piece 78 + 79 + Replace the current WebGPU stress test with the Blank product page. 80 + 81 + **Piece behavior:** 82 + - `boot` — fetch product info from `/api/blank`, set up Buy button 83 + - `paint` — render product page: 84 + - Product image/photo of the Blank laptop 85 + - "The Blank" title 86 + - Description text (what it is, what it runs, what comes with it) 87 + - Price 88 + - Stock indicator (if limited) 89 + - **BUY** button 90 + - `act` — handle Buy button click → `POST /api/blank?new=true` → redirect to Stripe 91 + - Optional: `preview` for link unfurling / social sharing 92 + 93 + **UI approach:** 94 + - Keep it minimal — the page should feel like the product itself 95 + - Could start with just text + a buy button (very blank) 96 + - Photo(s) can be loaded from DO Spaces CDN 97 + 98 + ### 3. Environment Variables (netlify.toml / .env) 99 + 100 + ``` 101 + STRIPE_ENDPOINT_BLANK_SECRET=whsec_... # Webhook signing secret for /api/blank 102 + ``` 103 + 104 + No new Stripe keys needed — reuses existing `STRIPE_API_PRIV_KEY` / `STRIPE_API_TEST_PRIV_KEY`. 105 + 106 + ### 4. Stripe Dashboard Setup 107 + 108 + - Create a new Product: "The Blank — AC Native Laptop" 109 + - Create a Price: $128.00 USD (one-time) 110 + - Register webhook endpoint: `https://aesthetic.computer/api/blank` 111 + - Events: `checkout.session.completed` 112 + - Note the webhook signing secret → `STRIPE_ENDPOINT_BLANK_SECRET` 113 + 114 + ### 5. MongoDB Schema 115 + 116 + ```javascript 117 + // Collection: blank-orders 118 + { 119 + _id: ObjectId, 120 + stripeSessionId: string, 121 + paymentIntentId: string, 122 + customer: { 123 + email: string, 124 + name: string, 125 + address: { /* shipping address from Stripe */ } 126 + }, 127 + product: { 128 + model: "yoga-11e-gen5", // or "yoga-11e-gen4" 129 + price: 12800, // cents 130 + }, 131 + status: "paid" | "preparing" | "shipped" | "delivered", 132 + trackingNumber: string | null, 133 + notes: string | null, 134 + createdAt: Date, 135 + updatedAt: Date, 136 + } 137 + ``` 138 + 139 + --- 140 + 141 + ## Implementation Order 142 + 143 + 1. **Stripe Dashboard** — Create product + price + webhook endpoint 144 + 2. **`blank.mjs` function** — API endpoint (checkout session + webhook) 145 + 3. **`blank.mjs` piece** — Product page with Buy button 146 + 4. **Test locally** — `stripe listen --forward-to "https://localhost:8888/api/blank"` 147 + 5. **Deploy + verify** — Test with Stripe test mode end-to-end 148 + 6. **Go live** — Switch to production keys, source first batch of laptops 149 + 150 + --- 151 + 152 + ## Pricing: Sliding Scale 153 + 154 + Follow the `give.js` pattern — let the buyer choose what to pay within a range. 155 + 156 + ```javascript 157 + const pricing = { 158 + usd: { min: 9600, suggested: 12800, max: 51200 }, // $96–$512, suggested $128 159 + dkk: { min: 67200, suggested: 89600, max: 358400 }, // ~672–3584 kr, suggested ~896 kr 160 + }; 161 + ``` 162 + 163 + **Tiers:** 164 + 165 + | Amount | What you get | 166 + |--------|-------------| 167 + | **$96** (minimum) | The Blank — laptop + AC Native OS | 168 + | **$128** (suggested) | The Blank + supports AC development | 169 + | **$512** (max) | The Blank + in-person meeting/tutorial in LA | 170 + 171 + - **$96** — covers hardware sourcing + OS flashing + shipping 172 + - **$128** — suggested default, surplus supports ongoing AC work 173 + - **$512** — includes an in-person session in Los Angeles: setup walkthrough, tutorial on making pieces, Q&A with Jeffrey 174 + - The piece UI shows a slider (or discrete tier buttons) to choose 175 + - $512 tier triggers an additional email/scheduling flow for the in-person meeting 176 + - Stripe `price_data.unit_amount` is set dynamically (same as `give.js`) 177 + - Metadata tracks the tier: `{ tier: "blank" | "blank+support" | "blank+tutorial" }` 178 + 179 + ## Shipping & Delivery Options 180 + 181 + **Shipping options in Stripe checkout:** 182 + 183 + | Option | Countries | Cost | 184 + |--------|-----------|------| 185 + | Hand delivery | US (local) / Denmark | Free | 186 + | USPS Priority Mail | US | ~$10–15 (flat rate padded envelope) | 187 + | International (PostNord / USPS) | DK + others | ~$20–30 | 188 + 189 + - `shipping_address_collection.allowed_countries: ["US", "DK"]` to start 190 + - `shipping_options` array in checkout session config for delivery method selection 191 + - Hand delivery adds a note field: "We'll reach out to arrange pickup/delivery" 192 + - Can expand countries later as demand shows up 193 + 194 + **Hand delivery flow:** 195 + - Buyer selects "Hand delivery (free)" at checkout 196 + - We get notified, reach out via email to arrange 197 + - Perfect for workshops, events, or local community 198 + 199 + ## Currencies 200 + 201 + Reuse the `give.js` dual-currency pattern: 202 + 203 + ```javascript 204 + const currencies = { 205 + usd: { symbol: "$", min: 9600, max: 51200, suggested: 12800 }, 206 + dkk: { symbol: "kr", min: 67200, max: 358400, suggested: 89600 }, 207 + }; 208 + ``` 209 + 210 + The piece auto-detects or lets the buyer toggle USD/DKK. 211 + 212 + --- 213 + 214 + ## Open Questions 215 + 216 + - [ ] How many units for the first batch? 217 + - [ ] Product photography — shoot the actual flashed laptop? 218 + - [ ] Should `blank:thanks` be a separate piece or a param/state of `blank.mjs`? 219 + - [ ] Inventory tracking — manual count in env var, or MongoDB stock field? 220 + - [ ] Should there be a waitlist if out of stock? 221 + - [ ] Exact sliding scale range — what's the true cost floor per unit?
+3
system/netlify.toml
··· 91 91 [functions.email] 92 92 external_node_modules = ["stripe"] 93 93 included_env_vars = ["NETLIFY_DEV", "CONTEXT", "AUTH0_M2M_CLIENT_ID", "AUTH0_M2M_SECRET", "SOTCE_STRIPE_API_PRIV_KEY", "SOTCE_STRIPE_API_PUB_KEY"] 94 + [functions.blank] 95 + external_node_modules = ["stripe", "nodemailer"] 96 + included_env_vars = ["CONTEXT", "STRIPE_API_TEST_PRIV_KEY", "STRIPE_API_PRIV_KEY", "STRIPE_ENDPOINT_DEV_SECRET", "STRIPE_ENDPOINT_BLANK_SECRET", "MONGODB_URI", "SMTP_SERVER", "SMTP_USER", "SMTP_PASS"] 94 97 [functions.print] 95 98 external_node_modules = ["got", "stripe", "nodemailer"] 96 99 included_env_vars = ["CONTEXT", "PRINTFUL_API_TOKEN", "STRIPE_API_TEST_PRIV_KEY", "STRIPE_API_PRIV_KEY", "STRIPE_ENDPOINT_DEV_SECRET", "STRIPE_ENDPOINT_SECRET"]
+344
system/netlify/functions/blank.mjs
··· 1 + // Blank, 26.03.20 2 + // AC Blank — AC Native Laptop checkout 3 + // Endpoint: /api/blank 4 + 5 + // GET: Returns product info (pricing, stock) 6 + // POST: Creates Stripe checkout session (?new=true) or handles webhook 7 + 8 + // Testing: 9 + // stripe listen --forward-to "https://localhost:8888/api/blank" 10 + 11 + import Stripe from "stripe"; 12 + import { respond } from "../../backend/http.mjs"; 13 + import { email } from "../../backend/email.mjs"; 14 + import { authorize } from "../../backend/authorization.mjs"; 15 + 16 + const dev = process.env.CONTEXT === "dev"; 17 + const stripeKey = dev 18 + ? process.env.STRIPE_API_TEST_PRIV_KEY 19 + : process.env.STRIPE_API_PRIV_KEY; 20 + 21 + // Pricing (cents) 22 + const pricing = { 23 + usd: { min: 9600, suggested: 12800, max: 51200 }, 24 + dkk: { min: 67200, suggested: 89600, max: 358400 }, 25 + }; 26 + 27 + // Shipping options 28 + const shippingOptions = [ 29 + { 30 + shipping_rate_data: { 31 + type: "fixed_amount", 32 + fixed_amount: { amount: 0, currency: "usd" }, 33 + display_name: "Hand delivery", 34 + delivery_estimate: { 35 + minimum: { unit: "business_day", value: 1 }, 36 + maximum: { unit: "business_day", value: 14 }, 37 + }, 38 + }, 39 + }, 40 + { 41 + shipping_rate_data: { 42 + type: "fixed_amount", 43 + fixed_amount: { amount: 1200, currency: "usd" }, 44 + display_name: "USPS Priority Mail", 45 + delivery_estimate: { 46 + minimum: { unit: "business_day", value: 2 }, 47 + maximum: { unit: "business_day", value: 5 }, 48 + }, 49 + }, 50 + }, 51 + { 52 + shipping_rate_data: { 53 + type: "fixed_amount", 54 + fixed_amount: { amount: 2500, currency: "usd" }, 55 + display_name: "International (DK / EU)", 56 + delivery_estimate: { 57 + minimum: { unit: "business_day", value: 7 }, 58 + maximum: { unit: "business_day", value: 21 }, 59 + }, 60 + }, 61 + }, 62 + ]; 63 + 64 + // DKK shipping options (same structure, different currency) 65 + const shippingOptionsDKK = [ 66 + { 67 + shipping_rate_data: { 68 + type: "fixed_amount", 69 + fixed_amount: { amount: 0, currency: "dkk" }, 70 + display_name: "Hand delivery", 71 + delivery_estimate: { 72 + minimum: { unit: "business_day", value: 1 }, 73 + maximum: { unit: "business_day", value: 14 }, 74 + }, 75 + }, 76 + }, 77 + { 78 + shipping_rate_data: { 79 + type: "fixed_amount", 80 + fixed_amount: { amount: 8400, currency: "dkk" }, 81 + display_name: "PostNord / International", 82 + delivery_estimate: { 83 + minimum: { unit: "business_day", value: 5 }, 84 + maximum: { unit: "business_day", value: 14 }, 85 + }, 86 + }, 87 + }, 88 + ]; 89 + 90 + function tierFromAmount(amount) { 91 + if (amount >= 51200) return "tutorial"; 92 + if (amount > 9600) return "support"; 93 + return "blank"; 94 + } 95 + 96 + export async function handler(event) { 97 + // CORS preflight 98 + if (event.httpMethod === "OPTIONS") { 99 + return respond(200, {}); 100 + } 101 + 102 + // GET: Product info 103 + if (event.httpMethod === "GET") { 104 + return respond(200, { 105 + product: "AC Blank", 106 + model: "Lenovo ThinkPad Yoga 11e (Gen 4/5)", 107 + description: 108 + "A surplus laptop running AC Native OS — a pared-down creative computing instrument with only stable, permanent commands. Like a blank tape waiting to be filled.", 109 + pricing, 110 + tiers: { 111 + blank: { label: "AC Blank", description: "Laptop + AC Native OS" }, 112 + support: { 113 + label: "AC Blank + Support", 114 + description: "Supports AC development", 115 + }, 116 + tutorial: { 117 + label: "AC Blank + Tutorial", 118 + description: "In-person meeting & tutorial in LA", 119 + }, 120 + }, 121 + }); 122 + } 123 + 124 + if (event.httpMethod !== "POST") { 125 + return respond(405, { error: "Method not allowed" }); 126 + } 127 + 128 + if (!stripeKey) { 129 + return respond(500, { error: "Stripe not configured" }); 130 + } 131 + 132 + const stripe = new Stripe(stripeKey); 133 + 134 + // 1. Create checkout session 135 + if (event.queryStringParameters?.new === "true") { 136 + try { 137 + const body = JSON.parse(event.body || "{}"); 138 + const currency = (body.currency || "usd").toLowerCase(); 139 + const currencyConfig = pricing[currency]; 140 + 141 + if (!currencyConfig) { 142 + return respond(400, { error: `Unsupported currency: ${currency}` }); 143 + } 144 + 145 + const amount = parseInt(body.amount) || currencyConfig.suggested; 146 + 147 + if (amount < currencyConfig.min || amount > currencyConfig.max) { 148 + return respond(400, { 149 + error: `Amount must be between ${currencyConfig.min} and ${currencyConfig.max} for ${currency.toUpperCase()}`, 150 + }); 151 + } 152 + 153 + const tier = tierFromAmount(amount); 154 + const domain = dev 155 + ? `https://${event.headers.host}` 156 + : "https://aesthetic.computer"; 157 + 158 + const amountDisplay = 159 + currency === "dkk" 160 + ? `${(amount / 100).toFixed(0)} kr` 161 + : `$${(amount / 100).toFixed(2)}`; 162 + 163 + let productName = "AC Blank"; 164 + if (tier === "tutorial") productName += " + Tutorial"; 165 + 166 + const sessionConfig = { 167 + line_items: [ 168 + { 169 + price_data: { 170 + currency, 171 + product_data: { 172 + name: productName, 173 + description: 174 + tier === "tutorial" 175 + ? "AC Native Laptop + in-person tutorial in Los Angeles" 176 + : "Surplus laptop running AC Native OS", 177 + }, 178 + unit_amount: amount, 179 + }, 180 + quantity: 1, 181 + }, 182 + ], 183 + metadata: { 184 + type: "blank", 185 + tier, 186 + model: "yoga-11e", 187 + amount: amountDisplay, 188 + currency, 189 + }, 190 + mode: "payment", 191 + shipping_address_collection: { allowed_countries: ["US", "DK"] }, 192 + shipping_options: 193 + currency === "dkk" ? shippingOptionsDKK : shippingOptions, 194 + success_url: `${domain}/blank~thanks`, 195 + cancel_url: `${domain}/blank`, 196 + automatic_tax: { enabled: true }, 197 + custom_text: { 198 + submit: { 199 + message: 200 + tier === "tutorial" 201 + ? "We'll reach out to schedule your in-person session in LA." 202 + : "We'll flash your Blank with AC Native OS and ship it your way.", 203 + }, 204 + }, 205 + custom_fields: [ 206 + { 207 + key: "note", 208 + label: { type: "custom", custom: "Add a note (optional)" }, 209 + type: "text", 210 + optional: true, 211 + }, 212 + ], 213 + }; 214 + 215 + // Prefill email if logged in 216 + const user = await authorize(event.headers); 217 + if (user?.email) sessionConfig.customer_email = user.email; 218 + 219 + const session = await stripe.checkout.sessions.create(sessionConfig); 220 + return respond(200, { location: session.url, sessionId: session.id }); 221 + } catch (error) { 222 + console.error("Blank checkout error:", error); 223 + return respond(500, { error: error.message }); 224 + } 225 + } 226 + 227 + // 2. Webhook handler 228 + const sig = event.headers["stripe-signature"]; 229 + if (!sig) { 230 + return respond(400, { error: "Missing stripe-signature header" }); 231 + } 232 + 233 + const secret = dev 234 + ? process.env.STRIPE_ENDPOINT_DEV_SECRET 235 + : process.env.STRIPE_ENDPOINT_BLANK_SECRET; 236 + 237 + let hookEvent; 238 + try { 239 + hookEvent = stripe.webhooks.constructEvent(event.body, sig, secret); 240 + } catch (err) { 241 + return respond(400, { message: `Webhook Error: ${err.message}` }); 242 + } 243 + 244 + if (hookEvent.type === "checkout.session.completed") { 245 + const session = await stripe.checkout.sessions.retrieve( 246 + hookEvent.data.object.id, 247 + { expand: ["line_items", "shipping_details", "customer_details"] }, 248 + ); 249 + 250 + if (session.payment_status !== "paid") { 251 + return respond(500, { message: "Payment not completed" }); 252 + } 253 + 254 + const metadata = hookEvent.data.object.metadata; 255 + const tier = metadata.tier || "blank"; 256 + const customerEmail = session.customer_details?.email; 257 + const customerName = session.shipping_details?.name || "Friend"; 258 + const note = 259 + session.custom_fields?.find((f) => f.key === "note")?.text?.value || null; 260 + 261 + console.log( 262 + `✅ Blank order: ${tier} tier, ${metadata.amount}, ${customerEmail}`, 263 + ); 264 + 265 + // Store order in MongoDB 266 + try { 267 + const { connect } = await import("../../backend/database.mjs"); 268 + const database = await connect(); 269 + const orders = database.db.collection("blank-orders"); 270 + 271 + await orders.insertOne({ 272 + stripeSessionId: session.id, 273 + paymentIntentId: session.payment_intent, 274 + customer: { 275 + email: customerEmail, 276 + name: customerName, 277 + address: session.shipping_details?.address || null, 278 + }, 279 + product: { 280 + model: metadata.model, 281 + tier, 282 + amount: parseInt(metadata.amount) || 0, 283 + currency: metadata.currency, 284 + }, 285 + note, 286 + status: "paid", 287 + trackingNumber: null, 288 + createdAt: new Date(), 289 + updatedAt: new Date(), 290 + }); 291 + } catch (dbErr) { 292 + console.error("Failed to store blank order:", dbErr); 293 + // Don't fail the webhook — order is paid, we'll handle manually 294 + } 295 + 296 + // Send confirmation email to buyer 297 + const tierLabel = 298 + tier === "tutorial" 299 + ? "AC Blank + Tutorial" 300 + : tier === "support" 301 + ? "AC Blank (thank you for your support!)" 302 + : "AC Blank"; 303 + 304 + await email({ 305 + to: customerEmail, 306 + subject: "your blank is coming!", 307 + html: ` 308 + <h2>${tierLabel}</h2> 309 + <p>hi ${customerName},</p> 310 + <p>thank you for your order! we'll flash your blank with AC Native OS and get it to you soon.</p> 311 + ${tier === "tutorial" ? "<p><b>we'll reach out separately to schedule your in-person tutorial session in los angeles.</b></p>" : ""} 312 + ${note ? `<p>your note: <em>${note}</em></p>` : ""} 313 + <p> 314 + <b><a href="https://aesthetic.computer">aesthetic.computer</a></b><br> 315 + <code>${session.payment_intent?.replace("pi_", "") || session.id}</code> 316 + </p> 317 + `, 318 + }); 319 + 320 + // Notify us internally 321 + await email({ 322 + to: "mail@aesthetic.computer", 323 + subject: `new blank order! (${tier}) — ${metadata.amount}`, 324 + html: ` 325 + <h2>New Blank Order</h2> 326 + <p><b>Tier:</b> ${tier}</p> 327 + <p><b>Amount:</b> ${metadata.amount}</p> 328 + <p><b>Customer:</b> ${customerName} (${customerEmail})</p> 329 + <p><b>Shipping:</b><br> 330 + ${session.shipping_details?.address?.line1 || "Hand delivery"}<br> 331 + ${session.shipping_details?.address?.line2 ? session.shipping_details.address.line2 + "<br>" : ""} 332 + ${session.shipping_details?.address?.city || ""}, ${session.shipping_details?.address?.state || ""} ${session.shipping_details?.address?.postal_code || ""}<br> 333 + ${session.shipping_details?.address?.country || ""} 334 + </p> 335 + ${note ? `<p><b>Note:</b> ${note}</p>` : ""} 336 + <p><b>Stripe:</b> ${session.payment_intent}</p> 337 + `, 338 + }); 339 + 340 + return respond(200, { received: true, tier }); 341 + } 342 + 343 + return respond(400, { message: `Unhandled event: ${hookEvent.type}` }); 344 + }
+383 -111
system/public/aesthetic.computer/disks/blank.mjs
··· 1 - // WebGPU Stress Test 2 - // Testing WebGPU renderer performance with many lines 1 + // blank, 26.03.20 2 + // AC Blank — AC Native Laptop product page & checkout 3 + 4 + const { floor, sin, min, max } = Math; 5 + 6 + // Pricing (cents) 7 + const pricing = { 8 + usd: { min: 9600, suggested: 12800, max: 51200, symbol: "$" }, 9 + dkk: { min: 67200, suggested: 89600, max: 358400, symbol: "kr" }, 10 + }; 11 + 12 + const tiers = [ 13 + { amount: 9600, dkk: 67200, label: "AC Blank", desc: "laptop + AC OS" }, 14 + { amount: 12800, dkk: 89600, label: "AC Blank", desc: "+ support AC" }, 15 + { amount: 51200, dkk: 358400, label: "AC Blank", desc: "+ tutorial in LA" }, 16 + ]; 3 17 4 - /* 📝 Engineering Notes 5 - Press UP/DOWN to adjust line count 6 - Press P to toggle performance overlay 7 - Press SPACE to toggle animation 8 - */ 18 + // Module state 19 + let amount = 12800; 20 + let currency = "usd"; 21 + let checkoutUrl = null; 22 + let checkoutReady = false; 23 + let checkoutError = null; 24 + let buyPending = false; 25 + let thanks = false; 9 26 27 + // UI elements 28 + let buyBtn = null; 29 + let tierBtns = []; 30 + let currencyBtn = null; 31 + 32 + // Animation 10 33 let frame = 0; 11 - let perfEnabled = true; 12 - let animating = true; 13 - let lineCount = 500; // Start with 500 lines 14 - const LINE_STEP = 100; // Increment/decrement by 100 15 - const MAX_LINES = 10000; 16 - const MIN_LINES = 10; 34 + 35 + const pad = 6; 36 + const charW = 8; 37 + const charH = 16; 17 38 18 - function boot({ api }) { 19 - api.webgpu.enabled = true; 20 - api.webgpu.perf(true); 39 + function displayAmount(amt, cur) { 40 + if (cur === "dkk") return `${(amt / 100).toFixed(0)} kr`; 41 + return `$${(amt / 100).toFixed(0)}`; 21 42 } 22 43 23 - function act({ event: e, api }) { 24 - if (e.is("keyboard:down:p")) { 25 - perfEnabled = !perfEnabled; 26 - api.webgpu.perf(perfEnabled); 44 + function tierLabel(amt) { 45 + if (amt >= 51200) return "tutorial"; 46 + if (amt > 9600) return "support"; 47 + return "blank"; 48 + } 49 + 50 + function getBuyText() { 51 + if (buyPending) return "CHECKING OUT..."; 52 + if (checkoutError) return "RETRY"; 53 + return `BUY ${displayAmount(amount, currency)}`; 54 + } 55 + 56 + async function boot({ params, ui, screen, cursor, hud, api }) { 57 + cursor("native"); 58 + hud.labelBack(); 59 + 60 + // Check for thanks state 61 + if (params[0] === "thanks") { 62 + thanks = true; 63 + return; 27 64 } 28 - if (e.is("keyboard:down:space")) { 29 - animating = !animating; 30 - } 31 - if (e.is("keyboard:down:arrowup")) { 32 - lineCount = Math.min(MAX_LINES, lineCount + LINE_STEP); 33 - console.log("Lines:", lineCount); 34 - } 35 - if (e.is("keyboard:down:arrowdown")) { 36 - lineCount = Math.max(MIN_LINES, lineCount - LINE_STEP); 37 - console.log("Lines:", lineCount); 65 + 66 + setupButtons(ui, screen); 67 + fetchCheckout(api); 68 + } 69 + 70 + function setupButtons(ui, screen) { 71 + const w = screen.width; 72 + const h = screen.height; 73 + 74 + // Buy button (bottom center) 75 + const buyText = getBuyText(); 76 + const buyW = buyText.length * charW + pad * 4; 77 + const buyH = charH + pad * 3; 78 + buyBtn = new ui.Button( 79 + floor((w - buyW) / 2), 80 + h - buyH - 12, 81 + buyW, 82 + buyH, 83 + ); 84 + 85 + // Tier buttons (3 across, above buy button) 86 + const tierW = floor(min(w - 24, 280)); 87 + const tierH = charH + pad * 2; 88 + const tierY = buyBtn.box.y - (tierH + 6) * 3 - 12; 89 + tierBtns = tiers.map((t, i) => { 90 + const btn = new ui.Button( 91 + floor((w - tierW) / 2), 92 + tierY + i * (tierH + 6), 93 + tierW, 94 + tierH, 95 + ); 96 + btn._tierIndex = i; 97 + return btn; 98 + }); 99 + 100 + // Currency toggle (top right) 101 + const curText = currency.toUpperCase(); 102 + const curW = curText.length * charW + pad * 2; 103 + currencyBtn = new ui.Button(w - curW - 8, 8, curW, charH + pad); 104 + } 105 + 106 + async function fetchCheckout(api) { 107 + checkoutReady = false; 108 + checkoutError = null; 109 + checkoutUrl = null; 110 + 111 + try { 112 + const headers = { "Content-Type": "application/json" }; 113 + const token = await api?.authorize?.(); 114 + if (token) headers.Authorization = `Bearer ${token}`; 115 + 116 + const res = await fetch("/api/blank?new=true", { 117 + method: "POST", 118 + headers, 119 + body: JSON.stringify({ amount, currency }), 120 + }); 121 + 122 + if (!res.ok) { 123 + checkoutError = `Checkout failed: ${res.status}`; 124 + return; 125 + } 126 + 127 + const data = await res.json(); 128 + if (data?.location) { 129 + checkoutUrl = data.location; 130 + checkoutReady = true; 131 + } else { 132 + checkoutError = data?.error || "Checkout failed"; 133 + } 134 + } catch (e) { 135 + checkoutError = e?.message || "Checkout error"; 38 136 } 39 137 } 40 138 41 - function paint({ wipe, ink, line, screen }) { 42 - if (animating) frame += 1; 43 - 44 - // Dark animated background 45 - const bgR = Math.floor(15 + Math.sin(frame * 0.005) * 10); 46 - const bgG = Math.floor(10 + Math.sin(frame * 0.007) * 8); 47 - const bgB = Math.floor(25 + Math.sin(frame * 0.009) * 12); 48 - wipe(bgR, bgG, bgB); 49 - 139 + function paint({ wipe, ink, write, box, line, screen }) { 140 + frame += 1; 50 141 const w = screen.width; 51 142 const h = screen.height; 52 - const cx = w / 2; 53 - const cy = h / 2; 54 - const time = frame * 0.02; 55 - 56 - // Draw many lines in various patterns 57 - for (let i = 0; i < lineCount; i++) { 58 - const t = i / lineCount; 59 - 60 - // Pattern selection based on line index 61 - const pattern = i % 4; 62 - let x1, y1, x2, y2; 63 - 64 - if (pattern === 0) { 65 - // Rotating spokes from center 66 - const angle = t * Math.PI * 8 + time; 67 - const len = Math.min(w, h) * 0.45 * (0.3 + 0.7 * Math.sin(t * Math.PI * 4 + time)); 68 - x1 = cx; 69 - y1 = cy; 70 - x2 = cx + Math.cos(angle) * len; 71 - y2 = cy + Math.sin(angle) * len; 72 - } else if (pattern === 1) { 73 - // Horizontal wave lines 74 - const yPos = t * h; 75 - const wave = Math.sin(yPos * 0.1 + time) * 30; 76 - x1 = wave; 77 - y1 = yPos; 78 - x2 = w + wave; 79 - y2 = yPos; 80 - } else if (pattern === 2) { 81 - // Spiral pattern 82 - const spiral = t * Math.PI * 6 + time * 0.5; 83 - const r1 = t * Math.min(w, h) * 0.4; 84 - const r2 = r1 + 20; 85 - x1 = cx + Math.cos(spiral) * r1; 86 - y1 = cy + Math.sin(spiral) * r1; 87 - x2 = cx + Math.cos(spiral + 0.2) * r2; 88 - y2 = cy + Math.sin(spiral + 0.2) * r2; 143 + 144 + // Dark background 145 + wipe(12, 12, 14); 146 + 147 + // Thanks page 148 + if (thanks) { 149 + const cy = floor(h / 2); 150 + ink(255).write("your blank is coming.", { 151 + center: "x", 152 + y: cy - 30, 153 + screen, 154 + }); 155 + ink(140).write("we'll be in touch.", { center: "x", y: cy, screen }); 156 + return; 157 + } 158 + 159 + // Title 160 + const titleY = floor(h * 0.08); 161 + ink(255).write("AC Blank", { 162 + center: "x", 163 + y: titleY, 164 + screen, 165 + }); 166 + 167 + // Subtitle 168 + ink(120).write("AC Native Laptop", { 169 + center: "x", 170 + y: titleY + charH + 4, 171 + screen, 172 + }); 173 + 174 + // Description block 175 + const descY = titleY + charH * 2 + 16; 176 + const descLines = [ 177 + "A surplus laptop running AC Native OS.", 178 + "Stable commands. Nothing extra.", 179 + "Like a blank tape waiting to be filled.", 180 + ]; 181 + descLines.forEach((ln, i) => { 182 + ink(90).write(ln, { center: "x", y: descY + i * (charH + 2), screen }); 183 + }); 184 + 185 + // Specs 186 + const specY = descY + descLines.length * (charH + 2) + 12; 187 + ink(70).write("Lenovo ThinkPad Yoga 11e", { 188 + center: "x", 189 + y: specY, 190 + screen, 191 + }); 192 + ink(50).write("11.6\" touchscreen · flip design", { 193 + center: "x", 194 + y: specY + charH + 2, 195 + screen, 196 + }); 197 + 198 + // Tier buttons 199 + tierBtns.forEach((btn, i) => { 200 + const t = tiers[i]; 201 + const tierAmt = currency === "dkk" ? t.dkk : t.amount; 202 + const isSelected = amount === tierAmt; 203 + const isHover = btn.down; 204 + 205 + let fillColor, borderColor, textColor; 206 + if (isSelected) { 207 + fillColor = [40, 50, 40]; 208 + borderColor = [120, 200, 120]; 209 + textColor = [220, 255, 220]; 210 + } else if (isHover) { 211 + fillColor = [35, 35, 40]; 212 + borderColor = [150, 150, 180]; 213 + textColor = [200, 200, 220]; 89 214 } else { 90 - // Random bouncing lines 91 - const seed = i * 1234.5678; 92 - const bx = (Math.sin(seed) * 0.5 + 0.5) * w; 93 - const by = (Math.cos(seed * 1.1) * 0.5 + 0.5) * h; 94 - const angle = seed + time; 95 - const len = 20 + Math.sin(seed * 2 + time) * 15; 96 - x1 = bx; 97 - y1 = by; 98 - x2 = bx + Math.cos(angle) * len; 99 - y2 = by + Math.sin(angle) * len; 215 + fillColor = [22, 22, 25]; 216 + borderColor = [60, 60, 65]; 217 + textColor = [140, 140, 145]; 100 218 } 101 - 102 - // Rainbow color based on position and time 103 - const hue = (t + frame * 0.002) % 1; 104 - const rgb = hslToRgb(hue, 0.8, 0.55); 105 - ink(rgb[0], rgb[1], rgb[2]); 106 - line(x1, y1, x2, y2); 219 + 220 + ink(...fillColor).box(btn.box, "fill"); 221 + ink(...borderColor).box(btn.box, "outline"); 222 + 223 + // Left: price 224 + const priceText = displayAmount(tierAmt, currency); 225 + ink(...textColor).write(priceText, { 226 + x: btn.box.x + pad * 2, 227 + y: btn.box.y + pad, 228 + }); 229 + 230 + // Right: description 231 + ink(...(isSelected ? [160, 200, 160] : [90, 90, 95])).write(t.desc, { 232 + x: btn.box.x + btn.box.w - t.desc.length * charW - pad * 2, 233 + y: btn.box.y + pad, 234 + }); 235 + 236 + // Selection indicator 237 + if (isSelected) { 238 + ink(120, 200, 120).write(">", { 239 + x: btn.box.x + pad - 2, 240 + y: btn.box.y + pad, 241 + }); 242 + } 243 + }); 244 + 245 + // Buy button 246 + if (buyBtn) { 247 + const buyText = getBuyText(); 248 + const isHover = buyBtn.down; 249 + const isPending = buyPending; 250 + 251 + // Recalculate width for current text 252 + const buyW = buyText.length * charW + pad * 4; 253 + buyBtn.box.w = buyW; 254 + buyBtn.box.x = floor((w - buyW) / 2); 255 + 256 + let fillColor, borderColor, textColor; 257 + if (isPending) { 258 + const pulse = sin(performance.now() / 150) * 0.5 + 0.5; 259 + fillColor = [30 + pulse * 30, 40 + pulse * 20, 30]; 260 + borderColor = [150 + pulse * 105, 200 + pulse * 55, 100]; 261 + textColor = [200 + pulse * 55, 220 + pulse * 35, 180]; 262 + } else if (checkoutError) { 263 + fillColor = [50, 25, 25]; 264 + borderColor = [200, 80, 80]; 265 + textColor = [255, 120, 120]; 266 + } else if (isHover) { 267 + fillColor = [40, 55, 40]; 268 + borderColor = [150, 255, 150]; 269 + textColor = [200, 255, 200]; 270 + } else { 271 + const blink = sin(performance.now() / 500) * 0.3 + 0.7; 272 + fillColor = [25, 35, 25]; 273 + borderColor = [ 274 + floor(80 + blink * 70), 275 + floor(160 + blink * 95), 276 + floor(80 + blink * 70), 277 + ]; 278 + textColor = [180, 230, 180]; 279 + } 280 + 281 + ink(...fillColor).box(buyBtn.box, "fill"); 282 + ink(...borderColor).box(buyBtn.box, "outline"); 283 + ink(...textColor).write(buyText, { 284 + x: buyBtn.box.x + pad * 2, 285 + y: buyBtn.box.y + floor(pad * 1.5), 286 + }); 287 + } 288 + 289 + // Currency toggle (top right) 290 + if (currencyBtn) { 291 + const curText = currency.toUpperCase(); 292 + const isHover = currencyBtn.down; 293 + ink(isHover ? 200 : 80).write(curText, { 294 + x: currencyBtn.box.x + pad, 295 + y: currencyBtn.box.y + floor(pad / 2), 296 + }); 297 + } 298 + 299 + // Subtle bottom line 300 + ink(30).line(0, h - 1, w, h - 1); 301 + } 302 + 303 + function act({ event: e, screen, jump, sound, ui, api }) { 304 + if (thanks) return; 305 + 306 + if (e.is("reframed")) { 307 + setupButtons(ui, screen); 107 308 } 309 + 310 + // Tier selection 311 + tierBtns.forEach((btn, i) => { 312 + btn.act(e, { 313 + down: () => { 314 + sound?.synth({ type: "sine", tone: 500 + i * 100, duration: 0.03, volume: 0.2 }); 315 + }, 316 + push: () => { 317 + const t = tiers[i]; 318 + const newAmount = currency === "dkk" ? t.dkk : t.amount; 319 + if (newAmount !== amount) { 320 + amount = newAmount; 321 + sound?.synth({ type: "sine", tone: 600 + i * 150, duration: 0.06, volume: 0.3 }); 322 + fetchCheckout(api); 323 + } 324 + }, 325 + }); 326 + }); 327 + 328 + // Currency toggle 329 + currencyBtn?.act(e, { 330 + push: () => { 331 + currency = currency === "usd" ? "dkk" : "usd"; 332 + // Map current amount to equivalent tier in new currency 333 + const tierIdx = tiers.findIndex( 334 + (t) => (currency === "usd" ? t.dkk : t.amount) === amount, 335 + ); 336 + if (tierIdx >= 0) { 337 + amount = 338 + currency === "dkk" ? tiers[tierIdx].dkk : tiers[tierIdx].amount; 339 + } else { 340 + amount = pricing[currency].suggested; 341 + } 342 + sound?.synth({ type: "sine", tone: 700, duration: 0.05, volume: 0.2 }); 343 + // Update currency button size 344 + const curText = currency.toUpperCase(); 345 + const curW = curText.length * charW + pad * 2; 346 + currencyBtn.box.w = curW; 347 + currencyBtn.box.x = screen.width - curW - 8; 348 + fetchCheckout(api); 349 + }, 350 + }); 351 + 352 + // Buy button 353 + buyBtn?.act(e, { 354 + down: () => { 355 + sound?.synth({ type: "sine", tone: 440, duration: 0.05, volume: 0.3 }); 356 + }, 357 + push: () => { 358 + if (buyPending) return; 359 + 360 + if (checkoutReady && checkoutUrl) { 361 + sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); 362 + jump(checkoutUrl); 363 + } else if (checkoutError) { 364 + // Retry 365 + checkoutError = null; 366 + fetchCheckout(api); 367 + sound?.synth({ type: "sine", tone: 550, duration: 0.06, volume: 0.3 }); 368 + } else { 369 + buyPending = true; 370 + sound?.synth({ type: "sine", tone: 660, duration: 0.08, volume: 0.3 }); 371 + waitForCheckout(jump, sound); 372 + } 373 + }, 374 + }); 108 375 } 109 376 110 - function hslToRgb(h, s, l) { 111 - let r, g, b; 112 - if (s === 0) { 113 - r = g = b = l; 114 - } else { 115 - const hue2rgb = (p, q, t) => { 116 - if (t < 0) t += 1; 117 - if (t > 1) t -= 1; 118 - if (t < 1/6) return p + (q - p) * 6 * t; 119 - if (t < 1/2) return q; 120 - if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; 121 - return p; 122 - }; 123 - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 124 - const p = 2 * l - q; 125 - r = hue2rgb(p, q, h + 1/3); 126 - g = hue2rgb(p, q, h); 127 - b = hue2rgb(p, q, h - 1/3); 377 + async function waitForCheckout(jump, sound) { 378 + const maxWait = 10000; 379 + const startTime = Date.now(); 380 + 381 + while (!checkoutReady && !checkoutError && Date.now() - startTime < maxWait) { 382 + await new Promise((r) => setTimeout(r, 100)); 383 + } 384 + 385 + buyPending = false; 386 + 387 + if (checkoutReady && checkoutUrl) { 388 + sound?.synth({ type: "sine", tone: 880, duration: 0.1, volume: 0.4 }); 389 + jump(checkoutUrl); 390 + } else if (checkoutError) { 391 + sound?.synth({ type: "square", tone: 200, duration: 0.15, volume: 0.3 }); 128 392 } 129 - return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 393 + } 394 + 395 + function meta() { 396 + return { 397 + title: "AC Blank", 398 + desc: "A surplus laptop running AC Native OS. Stable commands. Nothing extra. Like a blank tape waiting to be filled.", 399 + }; 130 400 } 401 + 402 + export { boot, paint, act, meta };