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:
parent
f3d88cc2a8
commit
4341653abe
6 changed files with 339 additions and 2 deletions
|
|
@ -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/* →
|
||||
|
|
|
|||
48
public/images/print/skatespots-poster.svg
Normal file
48
public/images/print/skatespots-poster.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 109 KiB |
26
public/images/print/skatespots-sticker.svg
Normal file
26
public/images/print/skatespots-sticker.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 106 KiB |
|
|
@ -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
|
||||
|
|
|
|||
165
scripts/make-print-art.mjs
Normal file
165
scripts/make-print-art.mjs
Normal 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);
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
</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 & 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 ───────────────────────────────────────────────── */}
|
||||
<section className="mt-10">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
|
|
|
|||
Loading…
Reference in a new issue