diff --git a/AGENTS.md b/AGENTS.md index d638863..fdaf29c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -359,7 +359,12 @@ session with **no other memory of this project**. So: only previews what's coming. **Do not add prices/fake urgency/buy buttons until there is real stock**; when items exist, model them as listings and take Lightning/Cashu payment. The page reads the live stock strip as 0/None/Nothing - — keep it honest if that changes. + — keep it honest if that changes. It also serves two **free printables** + (`public/images/print/skatespots-sticker.svg` 55×86 mm, `…-poster.svg` A3), + regenerated with `node scripts/make-print-art.mjs` (output is committed). Those + SVGs are self-contained: brand fonts + logo embedded as data URIs and a real + vector QR (verified decoding to https://skate-spots.org — if you regenerate, + re-verify the QR scans with the logo in the middle; error-correction level H). - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/public/images/print/skatespots-poster.svg b/public/images/print/skatespots-poster.svg new file mode 100644 index 0000000..ddec04e --- /dev/null +++ b/public/images/print/skatespots-poster.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + SKATESPOTS.APP + + + + + DO YOU EVEN SKATE + DO YOU EVEN SKATE + + BRO? + BRO? + FIND SPOTS. SESH TOGETHER. NO CAP. + + + + + + + + + + + + + + SCAN. DOWNLOAD. GET OUT THERE. + + zapstore.dev/apps/org.skatespots.app + diff --git a/public/images/print/skatespots-sticker.svg b/public/images/print/skatespots-sticker.svg new file mode 100644 index 0000000..969f9e5 --- /dev/null +++ b/public/images/print/skatespots-sticker.svg @@ -0,0 +1,26 @@ + + + + + + DO YOU EVEN SKATE + DO YOU EVEN SKATE + + BRO? + BRO? + + + + + + + + diff --git a/release-notes.txt b/release-notes.txt index a68dd81..58be796 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,6 +1,8 @@ v1.5.4 -🛍 App Shop — a new section (empty on purpose) +🛍 App Shop — a new section (empty on purpose, with free printables) +- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker + and an A3 poster, both print-ready vector files with a working QR. - Merch is coming, and it'll be paid in sats: Lightning or Cashu, your choice. No cards, no accounts, no processor skimming the drop. - To be clear: we hold no stock and nothing is for sale yet. No pre-orders, no diff --git a/scripts/make-print-art.mjs b/scripts/make-print-art.mjs new file mode 100644 index 0000000..0903f30 --- /dev/null +++ b/scripts/make-print-art.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +/** + * Generates the print-ready VECTOR artwork served by the App Shop: + * run with `node scripts/make-print-art.mjs` (output is committed, so this is + * only needed when the artwork changes). + * public/images/print/skatespots-sticker.svg (55 × 86 mm) + * public/images/print/skatespots-poster.svg (A3, 297 × 420 mm) + * + * Self-contained: brand fonts (Outfit variable + Barlow Condensed) and the logo + * are embedded as data URIs, and the QR is real vector from `qrcode`. That means + * it prints crisp at any size and needs no network. + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(join(ROOT, 'package.json')); +const QRCode = require('qrcode'); + +const OUT = join(ROOT, 'public/images/print'); +mkdirSync(OUT, { recursive: true }); + +const ORANGE = '#f08c2a'; +const WHITE = '#ffffff'; +const BG = '#0a0a0a'; +const QR_URL = 'https://skate-spots.org'; + +// ── assets ─────────────────────────────────────────────────────────────────── +const b64 = (p) => readFileSync(p).toString('base64'); +const outfit = b64(join(ROOT, 'node_modules/@fontsource-variable/outfit/files/outfit-latin-wght-normal.woff2')); +const barlow = b64(join(ROOT, 'node_modules/@fontsource/barlow-condensed/files/barlow-condensed-latin-600-normal.woff2')); +const logoPng = b64(join(ROOT, 'public/images/icon-1024.png')); + +const FONTS = ` + `; + +/** Real QR module path (no background) + its viewBox size. */ +async function qrPath() { + const svg = await QRCode.toString(QR_URL, { + type: 'svg', + margin: 0, + errorCorrectionLevel: 'H', + color: { dark: '#000000', light: '#ffffff' }, + }); + const m = svg.match(/]*d="([^"]+)"[^>]*\/?>/g) || []; + // The first path is the solid background; the rest are the modules. + const modulePath = (svg.match(/ + + + + + `; +} + +/** Block-shadowed headline: orange offset copy behind a white one. */ +function headline({ text, cx, y, size, width, offset, stroke = 0, letterSpacing = '0.01em' }) { + const common = `text-anchor="middle" x="${cx}" y="${y}" font-size="${size}" letter-spacing="${letterSpacing}"` + + (width ? ` textLength="${width}" lengthAdjust="spacingAndGlyphs"` : ''); + return ` + ${text} + ${text}`; +} + +/** Sawtooth ribbon of alternating triangles. dir: 'down' means apex points down. */ +function ribbon({ y, width, height, dir, count = 13 }) { + const w = width / count; + let out = ''; + for (let i = 0; i < count; i++) { + const x = i * w; + const fill = i % 2 === 0 ? ORANGE : WHITE; + const pts = + dir === 'down' + ? `${x},${y} ${x + w / 2},${y + height} ${x + w},${y}` + : `${x},${y + height} ${x + w / 2},${y} ${x + w},${y + height}`; + out += ``; + } + return out; +} + +const dashed = ({ y, x1, x2, color = '#3a3a3a', w = 4 }) => + ``; + +// ── sticker (portrait, 55 × 86 mm at 0.1 mm units) ─────────────────────────── +async function sticker() { + const qr = await qrPath(); + const W = 550, H = 860; + const qrSize = 400; + return ` + ${FONTS} + + ${headline({ text: 'DO YOU EVEN SKATE', cx: W / 2, y: 150, size: 92, width: 470, offset: 5, stroke: 2 })} + ${headline({ text: 'BRO?', cx: W / 2, y: 330, size: 190, width: 330, offset: 8, stroke: 3 })} + ${qrCard({ x: (W - qrSize) / 2, y: 420, size: qrSize, qr, radius: 44 })} +\n`; +} + +// ── poster (A3 portrait, 0.1 mm units → 2970 × 4200) ───────────────────────── +async function poster() { + const qr = await qrPath(); + const W = 2970, H = 4200; + const qrSize = 1500; + const cx = W / 2; + return ` + ${FONTS} + + + + ${ribbon({ y: 0, width: W, height: 74, dir: 'down', count: 13 })} + ${ribbon({ y: H - 74, width: W, height: 74, dir: 'up', count: 13 })} + + + + + SKATESPOTS.APP + ${dashed({ y: 250, x1: 130, x2: W - 130 })} + + + ${headline({ text: 'DO YOU EVEN SKATE', cx, y: 620, size: 300, width: 2350, offset: 12, stroke: 4 })} + ${headline({ text: 'BRO?', cx, y: 1000, size: 520, width: 1150, offset: 18, stroke: 6 })} + FIND SPOTS. SESH TOGETHER. NO CAP. + ${dashed({ y: 1240, x1: 130, x2: W - 130 })} + + + ${qrCard({ x: cx - qrSize / 2, y: 1370, size: qrSize, qr, radius: 70 })} + ${dashed({ y: 3260, x1: 130, x2: W - 130 })} + + + SCAN. DOWNLOAD. GET OUT THERE. + + zapstore.dev/apps/org.skatespots.app +\n`; +} + +const s = await sticker(); +const p = await poster(); +writeFileSync(join(OUT, 'skatespots-sticker.svg'), s); +writeFileSync(join(OUT, 'skatespots-poster.svg'), p); +console.log('sticker:', s.length, 'bytes'); +console.log('poster :', p.length, 'bytes'); +console.log('→', OUT); diff --git a/src/pages/AppShopPage.tsx b/src/pages/AppShopPage.tsx index 0c05efd..429ea95 100644 --- a/src/pages/AppShopPage.tsx +++ b/src/pages/AppShopPage.tsx @@ -13,6 +13,9 @@ import { MapPin, MessageCircle, ArrowRight, + Printer, + Download, + Scissors, } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton'; @@ -33,6 +36,26 @@ const REQUEST_HREF = `/messages/${SHOP_OWNER_NPUB}`; * Lightning/Cashu payment; until then this page must never imply a sale. */ +/** + * Free, printable artwork. Physical size is baked into the files (mm units), so + * "Actual size" printing gives an exact 55 × 86 mm sticker / A3 poster, and the + * QR in each one resolves to https://skate-spots.org. + */ +const FREE_PRINT = [ + { + title: 'Sticker', + size: '55 × 86 mm', + tip: 'Black die-cut. Print on sticker paper, cut along the black edge.', + file: '/images/print/skatespots-sticker.svg', + }, + { + title: 'Poster', + size: 'A3 · 297 × 420 mm', + tip: 'Prints on A3; shrink to A4 (50%) and it still scans fine.', + file: '/images/print/skatespots-poster.svg', + }, +]; + /** What we're working on. Not products, not prices — just intent. */ const PLANNED = [ { icon: Shirt, label: 'Tees & hoodies' }, @@ -157,6 +180,74 @@ export default function AppShopPage() { + {/* ── Free to print ────────────────────────────────────────────────── */} +
+
+

+ + Free to print +

+
+ + no sats needed + +
+ +
+ {FREE_PRINT.map((item) => ( +
+
+ {`SkateSpots + + Free + +
+
+
+

{item.title}

+ + {item.size} + +
+

+ + {item.tip} +

+ +
+
+ ))} +
+ +

+ Print at 100% / “Actual size” so the size is + exact and the QR stays sharp — it points at skate-spots.org. These are vector files, so + they scale to any size without going fuzzy. +

+
+ {/* ── How it'll work ───────────────────────────────────────────────── */}