feat(app-shop): add free printable sticker + poster (vector, real QR)

- public/images/print/skatespots-sticker.svg (55 x 86 mm) and
  skatespots-poster.svg (A3) — the free artwork to print and stick up. Physical
  size is baked into the SVG (mm units), so 'Actual size' printing is exact.
- Self-contained: Outfit/Barlow fonts + the logo embedded as data URIs, and a
  real vector QR. VERIFIED both QRs decode to https://skate-spots.org (decoded
  with jsQR from a rendered PNG; logo centre, error-correction H).
- New 'Free to print' section in the App Shop: previews, FREE badge, sizes,
  cutting tip, 'Open & print' + 'Save file' buttons, and a 100%/Actual-size note.
- scripts/make-print-art.mjs regenerates the artwork (committed output).
- AGENTS.md documents the assets + the re-verify-the-QR rule.
This commit is contained in:
SkateSpots 2026-09-27 22:16:43 +07:00
parent f3d88cc2a8
commit 4341653abe
6 changed files with 339 additions and 2 deletions

View file

@ -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 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 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 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 - **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 109 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 106 KiB

View file

@ -1,6 +1,8 @@
v1.5.4 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. - Merch is coming, and it'll be paid in sats: Lightning or Cashu, your choice.
No cards, no accounts, no processor skimming the drop. 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 - To be clear: we hold no stock and nothing is for sale yet. No pre-orders, no

165
scripts/make-print-art.mjs Normal file
View file

@ -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 = `
<style>
@font-face { font-family:'Outfit'; font-weight:100 900; font-style:normal; font-display:block;
src:url(data:font/woff2;base64,${outfit}) format('woff2'); }
@font-face { font-family:'Barlow Condensed'; font-weight:600; font-style:normal; font-display:block;
src:url(data:font/woff2;base64,${barlow}) format('woff2'); }
.h { font-family:'Outfit', system-ui, sans-serif; font-weight:900; }
.c { font-family:'Barlow Condensed', system-ui, sans-serif; font-weight:600; letter-spacing:0.18em; }
</style>`;
/** 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(/<path[^>]*d="([^"]+)"[^>]*\/?>/g) || [];
// The first path is the solid background; the rest are the modules.
const modulePath = (svg.match(/<path stroke="#000000" d="([^"]+)"/) || [])[1];
const vb = (svg.match(/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/) || [])[1] || '31';
return { d: modulePath, size: Number(vb), all: m.length };
}
/** White QR card: panel + modules + centred logo. Units are the caller's. */
function qrCard({ x, y, size, qr, radius, logoFrac = 0.26 }) {
const pad = size * 0.055; // quiet-ish inset
const inner = size - pad * 2;
const s = inner / qr.size;
const logoS = size * logoFrac;
const logoX = x + size / 2 - logoS / 2;
const logoY = y + size / 2 - logoS / 2;
const discR = logoS * 0.62;
return `
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${radius}" fill="${WHITE}"/>
<g transform="translate(${x + pad} ${y + pad}) scale(${s})">
<path fill="none" stroke="#000000" stroke-width="1" d="${qr.d}"/>
</g>
<circle cx="${x + size / 2}" cy="${y + size / 2}" r="${discR}" fill="${WHITE}"/>
<image x="${logoX}" y="${logoY}" width="${logoS}" height="${logoS}"
href="data:image/png;base64,${logoPng}" preserveAspectRatio="xMidYMid meet"/>`;
}
/** 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 class="h" ${common} fill="${ORANGE}" transform="translate(${offset} ${offset})">${text}</text>
<text class="h" ${common} fill="${WHITE}" ${stroke ? `stroke="${ORANGE}" stroke-width="${stroke}" paint-order="stroke"` : ''}>${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 += `<polygon points="${pts}" fill="${fill}"/>`;
}
return out;
}
const dashed = ({ y, x1, x2, color = '#3a3a3a', w = 4 }) =>
`<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${color}" stroke-width="${w}" stroke-dasharray="18 14"/>`;
// ── 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 `<svg xmlns="http://www.w3.org/2000/svg" width="55mm" height="86mm" viewBox="0 0 ${W} ${H}">
${FONTS}
<rect width="${W}" height="${H}" rx="34" fill="#000000"/>
${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 })}
</svg>\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 `<svg xmlns="http://www.w3.org/2000/svg" width="297mm" height="420mm" viewBox="0 0 ${W} ${H}">
${FONTS}
<rect width="${W}" height="${H}" fill="${BG}"/>
<!-- sawtooth ribbons -->
${ribbon({ y: 0, width: W, height: 74, dir: 'down', count: 13 })}
${ribbon({ y: H - 74, width: W, height: 74, dir: 'up', count: 13 })}
<!-- header -->
<rect x="120" y="150" width="30" height="30" fill="${ORANGE}"/>
<rect x="${W - 150}" y="150" width="30" height="30" fill="${ORANGE}"/>
<text class="c" x="${cx}" y="188" fill="${WHITE}" text-anchor="middle" font-size="72" textLength="860" lengthAdjust="spacingAndGlyphs">SKATESPOTS.APP</text>
${dashed({ y: 250, x1: 130, x2: W - 130 })}
<!-- headline -->
${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 })}
<text class="c" x="${cx}" y="1140" fill="${ORANGE}" text-anchor="middle" font-size="104" textLength="1750" lengthAdjust="spacingAndGlyphs">FIND SPOTS. SESH TOGETHER. NO CAP.</text>
${dashed({ y: 1240, x1: 130, x2: W - 130 })}
<!-- qr -->
${qrCard({ x: cx - qrSize / 2, y: 1370, size: qrSize, qr, radius: 70 })}
${dashed({ y: 3260, x1: 130, x2: W - 130 })}
<!-- footer -->
<text class="c" x="${cx}" y="3480" fill="${WHITE}" text-anchor="middle" font-size="130" textLength="1950" lengthAdjust="spacingAndGlyphs">SCAN. DOWNLOAD. GET OUT THERE.</text>
<line x1="130" y1="3600" x2="${W - 130}" y2="3600" stroke="${ORANGE}" stroke-width="5"/>
<text class="c" x="${cx}" y="3700" fill="#8a8a8a" text-anchor="middle" font-size="80" letter-spacing="0.04em" textLength="1250" lengthAdjust="spacingAndGlyphs">zapstore.dev/apps/org.skatespots.app</text>
</svg>\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);

View file

@ -13,6 +13,9 @@ import {
MapPin, MapPin,
MessageCircle, MessageCircle,
ArrowRight, ArrowRight,
Printer,
Download,
Scissors,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton'; 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. * 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. */ /** What we're working on. Not products, not prices — just intent. */
const PLANNED = [ const PLANNED = [
{ icon: Shirt, label: 'Tees & hoodies' }, { icon: Shirt, label: 'Tees & hoodies' },
@ -157,6 +180,74 @@ export default function AppShopPage() {
</div> </div>
</section> </section>
{/* ── Free to print ────────────────────────────────────────────────── */}
<section className="mt-10">
<div className="mb-4 flex items-center gap-3">
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-widest text-white">
<Printer className="h-4 w-4 text-[#ccff00]" />
Free to print
</h3>
<div className="h-px flex-1 bg-white/10" />
<span className="text-[10px] font-bold uppercase tracking-widest text-[#ccff00]">
no sats needed
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{FREE_PRINT.map((item) => (
<div
key={item.title}
className="overflow-hidden rounded-2xl border border-white/8 bg-white/[0.03]"
>
<div className="relative flex h-56 items-center justify-center bg-black/40 p-4">
<img
src={item.file}
alt={`SkateSpots ${item.title} — printable`}
className="max-h-full max-w-full drop-shadow-[0_6px_20px_rgba(0,0,0,0.6)]"
/>
<span className="absolute left-3 top-3 rounded-full bg-[#ccff00] px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-black">
Free
</span>
</div>
<div className="p-4">
<div className="flex items-baseline justify-between gap-2">
<p className="font-black uppercase tracking-wide text-white">{item.title}</p>
<span className="shrink-0 text-[10px] font-bold uppercase tracking-widest text-gray-500">
{item.size}
</span>
</div>
<p className="mt-1 flex items-start gap-1.5 text-xs leading-relaxed text-gray-400">
<Scissors className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-600" />
{item.tip}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
asChild
size="sm"
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<a href={item.file} target="_blank" rel="noopener noreferrer">
<Printer className="h-3.5 w-3.5" /> Open &amp; print
</a>
</Button>
<Button asChild size="sm" variant="outline" className="gap-2 border-white/15">
<a href={item.file} download>
<Download className="h-3.5 w-3.5" /> Save file
</a>
</Button>
</div>
</div>
</div>
))}
</div>
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
Print at <strong className="text-gray-400">100% / “Actual size”</strong> 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.
</p>
</section>
{/* ── How it'll work ───────────────────────────────────────────────── */} {/* ── How it'll work ───────────────────────────────────────────────── */}
<section className="mt-10"> <section className="mt-10">
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">