diff --git a/CLAUDE.md b/CLAUDE.md index be0be39..0eb311b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ HTML. - Orchestrates component layout - Handles SEO meta tags -**Islands (Interactive Components - 40 registered)** +**Islands (Interactive Components - 41 registered)** See GLOSSARY.md for complete list organized by category. Key islands: @@ -224,7 +224,7 @@ QRBuddy follows Pablo's "Soft Brutal" aesthetic: - **Chunky borders**: 4px black borders with custom shadow classes - **Warm pastels**: Cream backgrounds with gradient accents - **Spring animations**: Squish, rotate-shuffle, and pop effects -- **Gradient themes**: 7 pre-defined gradient styles emphasizing visual delight +- **Gradient themes**: 8 pre-defined gradient styles emphasizing visual delight ### Layout & Surface Rules @@ -247,8 +247,43 @@ adding surfaces: border-black sm:rounded-3xl`, `sm:animate-pop-in`); `max-h-[92dvh]`; header/body `p-4 sm:p-6`; bottom padding respects the home indicator: `pb-[max(1rem,env(safe-area-inset-bottom))] sm:pb-6`. -- **Info dialogs** (About/Kofi/Pricing): centered at all sizes, gradient - header + `border-4` card, same backdrop/z as sheets. +- **CreateModal footprint**: `max-w-lg`, cream card, body fixed at + `sm:h-[min(480px,56dvh)]` so the three tabs never change the dialog's height — + content scrolls inside. Every selectable is `ChoiceRow` (`rich` variant for + the Behavior tab): state by inversion only — active is + `bg-amber-200 border-black shadow-chunky`, inactive `border-black/15`. No + "Active" pills, no eyebrow tags, no dashed boxes, no tinted wrapper cards, no + "what you just picked" header card inside a form. Inputs share one recipe: + `border-2 border-black/15 bg-white rounded-2xl focus:border-qr-pop`. Expanding + sub-panels (`islands/extras/*`) are all + `bg-white border-2 border-black rounded-2xl p-4` — white on the cream card, + never a per-feature tint. Scroll containers inside cards use `scrollbar-none` + (styles.css) so the native track never cuts a corner radius. The tab strip is + warm milk (`bg-amber-50`), never clinical white. +- **Info dialogs** (About/Kofi/Pricing): always `islands/modal/CardModal.tsx` — + centered cream card at all sizes, `max-w-[460px]`, `border-4` + `rounded-3xl`, + mascot badge hanging centered at the top, circular `✕` top-right, same + backdrop/z as sheets. No gradient header bars. Titles are + `font-black text-2xl tracking-tight`; the one accent is `qr-pop` pink; primary + action is one full-width `rounded-full` pill (`CardPrimaryButton`), + secondaries are white bordered pills. Copy is a manifesto, not a spec sheet. +- **Footer dock**: `islands/FooterDock.tsx` — no divider line, metadata left, + utility pills right, Solutions pops upward from the dock. Home routes pin it + to the viewport bottom edge (`min-h-[100dvh]` shell, `mt-auto` footer). +- **Top rail**: language toggle and style picker share one lightweight pill + recipe + (`min-h-[36px] rounded-full border-2 text-xs font-black shadow-chunky`). +- **Voice**: QRBuddy is the entity. Never "we"/"us" in UI copy. +- **Palette presets** (`utils/qr-styles.ts` + `STYLE_DISPLAY`): one family — + pastel-punk, two-tone, the same 135° diagonal (`DIAGONAL` rad). Scannability + floor for any preset or swatch: every dot stop ≤ 0.40 relative luminance and ≥ + 2:1 against its own background. Pretty-but-pale lime/amber stops fail phone + cameras on cream paper — don't add them. Swatch previews are always + `linear-gradient(135deg, …)` so the grid catches the light uniformly. Gallery + tiles are enamel badges: gradient in a black ring with a white bezel, name in + black below, active = `bg-amber-200` inversion. Retired styles (terminal, + brutalist) stay in `QR_STYLES` for easter eggs and locker pages but leave the + public gallery. - **Toasts**: bottom snackbar, `bottom-[max(1.5rem,env(safe-area-inset-bottom))]`, full-width flex container (never `left-1/2` — it caps layout width at 50vw and wraps). @@ -300,7 +335,7 @@ adding surfaces: ### Performance Optimizations -- **Islands Architecture**: Only interactive components hydrate (40 islands +- **Islands Architecture**: Only interactive components hydrate (41 islands total) - **Signal-Based State**: Efficient reactive updates without re-renders - **Lazy QR Updates**: QR regenerates only on url/style change via useEffect @@ -309,6 +344,65 @@ adding surfaces: ## ✅ Recently Completed +### "It's mine" — share ownership + stats (Latest) + +Every file share (`/f/{code}`) has an owner. `upload-file` mints `owner_token` +(returned once, kept in the device's token vault under scope `"file"`, never in +the URL). On the share page, `islands/OwnerStrip.tsx` renders only when the +vault holds the token; the public never sees it. + +- **Stats that help, nothing creepy.** `share-beacon` is called from the browser + (a "view" on open, one "engagement" batch on page-hide) so the edge sees the + visitor's real headers. `_shared/visitor.ts` turns them into + country/city/device/os, a "scan vs shared link" flag (referrer), and a + daily-salted hash for uniques/returns — the IP is used and dropped in the same + call, never stored. `record_share_activity` (service-role RPC) merges into + `destructible_files.stats` (lifetime tallies) and `share_stats_daily` (90-day + ledger, pruned by `cleanup-expired`). `utils/share-stats.ts` folds these into + the Today / This week / All time trading card. Lifetime shows scans, not + people — not honestly knowable. +- **Context that people enjoy** (`20260925010000_share_stats_context.sql`): + languages (`accept-language`), which app carried the link (bucketed referrer + host), farthest scan (coarse ~10 km lat/lon vs `origin_lat/lon` recorded at + upload, haversine in SQL), night-owl / weekend reads from the hour/day + tallies, iPhone vs Android, age + milestones. No horoscopes. +- **The noticing** (`20260925020000_share_stats_conditions.sql`, + `_shared/conditions.ts`): each view tallies condition keys — weather, temp + band, local time slot, moon, plus "hot mornings" / "rainy nights" combos. + Weather is Open-Meteo (keyless), cached per ~10 km cell per UTC day in + `weather_days` with a 7-day backfill (≤1 outbound call per cell per day). + `utils/share-stats.ts` `patternsFor` compares observed vs a fair baseline (how + often it actually rained where the scanners were; share of days in each moon + phase) and only speaks at **≥20 views, ≥5 in the condition, lift ≥1.5×**. + "Just Melbourne" stays quiet. Cross-share "Field Notes" (OkTrends-style, + `/notes`) is the planned follow-up once real data exists. +- **Edit what it is.** `update-file`: `rename`, `retheme`, `append` (multipart), + `remove`. Self-destructing shares are frozen to rename/retheme; append can't + change a share's kind; max 10 items. +- **`SHARE_STATS_SALT`** must be set in edge-function secrets for "people" + counts; without it views still count. +- The Time Machine drawer shows **👑 Manage →** on shares this device owns. + +### Drop → Slideshow / Mixtape + +The magic path: drop 2+ photos on the input → a shareable slideshow; drop 2+ +audio files → a mixtape. Both live at `/f/{code}` (`FileSlideshow`). + +- **`utils/image-prep.ts`**: photos are downscaled on the device (≤2048px JPEG) + before upload so the 5MB-per-file multi-share cap never surfaces; + Safari-decoded HEIC comes out as JPEG. Non-decodable files pass through. +- **Staging card** (`smart-input/FileUploadOptions.tsx`): shows the thing — + thumbnail strip or tracklist — with an optional title and one CTA ("Make the + slideshow ✨"). Self-destruct is tucked under a toggle. +- **`smart-input/ShareReady.tsx`**: after upload, the "it's alive" strip: Open ↗ + · Copy · Sticker (turns on the SCAN ME frame and exports the PNG). +- **Title** travels as a `title` form field to `upload-file`, stored in + `original_name`; untitled multi-shares are named "8 photos" / "5 tracks" + (legacy "IMG_1.jpg + 7 more" names are read as such on the page). +- **`/f/` page**: photo slideshows autoplay (4.5s, space to pause, any manual + nav stops it) with a dot transport; playlists show a real tracklist with + cleaned names (`prettyName`) and auto-advance. + ### File Transfer Hardening (Latest) - **Atomic Destructible Downloads**: File claims/finalization run through @@ -330,10 +424,11 @@ adding surfaces: cleanup-expired - **Database Schema**: Added `dynamic_qr_codes` table with scan limits and expiry tracking -- **Edge Functions**: 18 total Supabase functions: +- **Edge Functions**: 20 total Supabase functions: - Dynamic QRs: create-dynamic-qr, update-dynamic-qr, get-dynamic-qr, redirect-qr - - Destructible files: upload-file, get-file, get-file-metadata + - Destructible files: upload-file, get-file, get-file-metadata, update-file, + share-beacon - File lockers: create-bucket, get-bucket-status, upload-to-bucket, download-from-bucket - Supporter pass: create-checkout, square-webhook, get-license @@ -349,7 +444,7 @@ adding surfaces: redirect logic - **Visual Indicators**: Purple/pink "editable" badge on dynamic QRs alongside destructible badge -- **40 Interactive Islands**: Complete island architecture with modals, QR +- **41 Interactive Islands**: Complete island architecture with modals, QR types, and feature discovery ### Previous Features diff --git a/GLOSSARY.md b/GLOSSARY.md index 13de352..e98a5a9 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -1,6 +1,6 @@ # Glossary - QRBuddy -## Islands (Interactive Components - 40 registered) +## Islands (Interactive Components - 41 registered) **Core QR Components:** @@ -10,7 +10,14 @@ - `StyleSelector` - Gradient picker with quick menu + custom entry point - `GradientCreator` - Full modal for custom gradients - `CreateModal` - Combined QR type, options, file locker, and design sheet -- `LogoUploader` - Custom center-logo uploader with previews +- `smart-input/FileUploadOptions` - Staging card: thumbnails/tracklist, title, + CTA, self-destruct toggle +- `smart-input/ShareReady` - Post-upload strip: Open / Copy / Sticker +- `OwnerStrip` - "It's yours" on /f/: stats card, rename, vibe, add/remove, + sticker +- `create-modal/PalettePills` - The one row of preset swatch pills +- `LogoUploader` - Center-logo row (ChoiceRow language) with inline + Upload/Remove - `ActionButtons` - Download/copy/share triggers tied to signals - `ShareActions` - Share sheet for QR codes - `HistoryDrawer` - QR generation history panel @@ -22,9 +29,10 @@ **Modals & Overlays:** -- `AboutModal` - Feature overview + release notes +- `AboutModal` - Manifesto card (first-visit intro poster + about) - `KofiModal` - Ko-fi support modal -- `PricingModal` - Pro tier upsell modal +- `PricingModal` - Single-tier Supporter unlock card +- `FooterDock` - Bottom dock: metadata + About/Solutions/Tip Jar/Supporter pills **Dynamic + Locker Features:** @@ -117,7 +125,7 @@ - **Signal-Based State** - Preact signals for reactive updates (url, style, triggerDownload, triggerCopy, isDestructible, isDynamic, editUrl) -- **Gradient QR Codes** - `qr-code-styling` library with 7 pre-defined gradient +- **Gradient QR Codes** - `qr-code-styling` library with 8 pre-defined gradient themes - **Island Architecture** - Selective client hydration, server-rendered HTML - **Soft Brutal Aesthetic** - Chunky 4px borders, warm pastels, spring diff --git a/README.md b/README.md index 88eec60..ada5ef6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ and Supabase security/RPC behavior should be tested against Supabase. ### 🎯 What Works Locally -- ✅ **Basic QR Codes** - All 7 gradient styles + custom gradients +- ✅ **Basic QR Codes** - All 8 gradient styles + custom gradients - ✅ **Destructible QRs** - Basic URL/file flows through the local mock API - ✅ **Dynamic QR Codes** - Editable redirects with scan limits & expiry (works through the local mock API) @@ -101,7 +101,7 @@ qrbuddy/ │ ├── f/[code].tsx # Destructible file download gate │ ├── bucket/[code].tsx # File locker viewer │ └── api/download-file.ts # Server-side file proxy -├── islands/ # Interactive Preact islands (40 registered) +├── islands/ # Interactive Preact islands (41 registered) │ ├── QRCanvas.tsx # Core QR rendering + download/copy logic │ ├── SmartInput.tsx # Smart input (URLs/files/text + dynamic settings) │ ├── StyleSelector.tsx # Gradient selector + custom creator entry diff --git a/fresh.gen.ts b/fresh.gen.ts index eb6dbe8..6d9ddc4 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -25,9 +25,11 @@ import * as $ErrorBoundary from "./islands/ErrorBoundary.tsx"; import * as $FileSlideshow from "./islands/FileSlideshow.tsx"; import * as $GradientCreator from "./islands/GradientCreator.tsx"; import * as $HistoryDrawer from "./islands/HistoryDrawer.tsx"; +import * as $FooterDock from "./islands/FooterDock.tsx"; import * as $KofiModal from "./islands/KofiModal.tsx"; import * as $LogoUploader from "./islands/LogoUploader.tsx"; import * as $NoteCard from "./islands/NoteCard.tsx"; +import * as $OwnerStrip from "./islands/OwnerStrip.tsx"; import * as $PricingModal from "./islands/PricingModal.tsx"; import * as $QRCanvas from "./islands/QRCanvas.tsx"; import * as $QRReader from "./islands/QRReader.tsx"; @@ -39,6 +41,7 @@ import * as $ToastManager from "./islands/ToastManager.tsx"; import * as $create_modal_ChoiceRow from "./islands/create-modal/ChoiceRow.tsx"; import * as $create_modal_CompletionState from "./islands/create-modal/CompletionState.tsx"; import * as $create_modal_DesignTab from "./islands/create-modal/DesignTab.tsx"; +import * as $create_modal_PalettePills from "./islands/create-modal/PalettePills.tsx"; import * as $edit_qr_AnalyticsDashboard from "./islands/edit-qr/AnalyticsDashboard.tsx"; import * as $edit_qr_QRStatusCard from "./islands/edit-qr/QRStatusCard.tsx"; import * as $edit_qr_RoutingConfigForm from "./islands/edit-qr/RoutingConfigForm.tsx"; @@ -49,6 +52,7 @@ import * as $extras_LockerSettings from "./islands/extras/LockerSettings.tsx"; import * as $extras_MultiLinkSettings from "./islands/extras/MultiLinkSettings.tsx"; import * as $extras_SplashSettings from "./islands/extras/SplashSettings.tsx"; import * as $extras_TimeBombSettings from "./islands/extras/TimeBombSettings.tsx"; +import * as $modal_CardModal from "./islands/modal/CardModal.tsx"; import * as $modal_ModalShell from "./islands/modal/ModalShell.tsx"; import * as $modal_useModalShell from "./islands/modal/useModalShell.ts"; import * as $smart_input_FileUploadOptions from "./islands/smart-input/FileUploadOptions.tsx"; @@ -90,9 +94,11 @@ const manifest = { "./islands/FileSlideshow.tsx": $FileSlideshow, "./islands/GradientCreator.tsx": $GradientCreator, "./islands/HistoryDrawer.tsx": $HistoryDrawer, + "./islands/FooterDock.tsx": $FooterDock, "./islands/KofiModal.tsx": $KofiModal, "./islands/LogoUploader.tsx": $LogoUploader, "./islands/NoteCard.tsx": $NoteCard, + "./islands/OwnerStrip.tsx": $OwnerStrip, "./islands/PricingModal.tsx": $PricingModal, "./islands/QRCanvas.tsx": $QRCanvas, "./islands/QRReader.tsx": $QRReader, @@ -104,6 +110,7 @@ const manifest = { "./islands/create-modal/ChoiceRow.tsx": $create_modal_ChoiceRow, "./islands/create-modal/CompletionState.tsx": $create_modal_CompletionState, "./islands/create-modal/DesignTab.tsx": $create_modal_DesignTab, + "./islands/create-modal/PalettePills.tsx": $create_modal_PalettePills, "./islands/edit-qr/AnalyticsDashboard.tsx": $edit_qr_AnalyticsDashboard, "./islands/edit-qr/QRStatusCard.tsx": $edit_qr_QRStatusCard, "./islands/edit-qr/RoutingConfigForm.tsx": $edit_qr_RoutingConfigForm, @@ -114,6 +121,7 @@ const manifest = { "./islands/extras/MultiLinkSettings.tsx": $extras_MultiLinkSettings, "./islands/extras/SplashSettings.tsx": $extras_SplashSettings, "./islands/extras/TimeBombSettings.tsx": $extras_TimeBombSettings, + "./islands/modal/CardModal.tsx": $modal_CardModal, "./islands/modal/ModalShell.tsx": $modal_ModalShell, "./islands/modal/useModalShell.ts": $modal_useModalShell, "./islands/smart-input/FileUploadOptions.tsx": diff --git a/hooks/useFileUpload.ts b/hooks/useFileUpload.ts index 7b8e571..cdcd92f 100644 --- a/hooks/useFileUpload.ts +++ b/hooks/useFileUpload.ts @@ -12,6 +12,8 @@ import { } from "../utils/file-validation.ts"; import { getSupporterPass } from "../utils/supporter-pass.ts"; import { uploadViaR2 } from "../utils/r2-upload.ts"; +import { saveOwnerToken } from "../utils/token-vault.ts"; +import { addToHistory } from "../utils/history.ts"; import { UNLIMITED_SCANS, UNLIMITED_SCANS_TEXT, @@ -47,7 +49,10 @@ export function useFileUpload( const [uploadError, setUploadError] = useState(null); const inFlightRef = useRef(false); - const uploadFile = async (input: File | FileList | File[]) => { + const uploadFile = async ( + input: File | FileList | File[], + options: { title?: string } = {}, + ) => { if (inFlightRef.current) return; inFlightRef.current = true; @@ -102,6 +107,8 @@ export function useFileUpload( fileName: string; size: number; maxDownloads: number; + fileId?: string; + ownerToken?: string; }; let data: UploadResponse; @@ -123,6 +130,9 @@ export function useFileUpload( formData.append("file", file); }); formData.append("maxDownloads", maxDownloads.value.toString()); + if (options.title?.trim()) { + formData.append("title", options.title.trim()); + } if (qrStyle?.value) { formData.append("theme", qrStyle.value); } @@ -139,6 +149,31 @@ export function useFileUpload( setUploadProgress(100); + // It's yours: keep the owner token in the vault (this device) and a + // "my QRs" entry so the share can be found and managed again. + if (data.fileId && data.ownerToken) { + await saveOwnerToken("file", data.fileId, data.ownerToken); + const allAudio = files.every((f) => f.type.startsWith("audio/")); + const allImages = files.every((f) => f.type.startsWith("image/")); + addToHistory({ + type: "media", + content: data.url, + metadata: { + title: options.title?.trim() || + (isMulti + ? `${files.length} ${ + allAudio ? "tracks" : allImages ? "photos" : "files" + }` + : files[0].name), + ownerScope: "file", + ownerId: data.fileId, + kind: isMulti + ? (allAudio ? "playlist" : allImages ? "slideshow" : "pack") + : "file", + }, + }); + } + // Set the destructible URL url.value = data.url; isDestructible.value = maxDownloads.value !== UNLIMITED_SCANS; @@ -168,23 +203,18 @@ export function useFileUpload( if (isMulti) { const allAudio = files.every((f) => f.type.startsWith("audio/")); const allImages = files.every((f) => f.type.startsWith("image/")); - const multiDesc = allAudio - ? `${files.length} tracks (playlist 🎵)` + successMessage = allAudio + ? "Mixtape is live 🎵" : allImages - ? `${files.length} images (slideshow 🖼️)` - : `${files.length} files`; - - successMessage = limitedDownloads - ? `✅ ${multiDesc} uploaded! Limit: ${scanText}` - : `✅ ${multiDesc} uploaded! Ready to share ✨`; + ? "Slideshow is live 🖼️" + : `${files.length} files are live 📦`; } else { - successMessage = limitedDownloads - ? `✅ ${files[0].name} uploaded! Limit: ${scanText}` - : `✅ ${files[0].name} uploaded! Ready to share ✨`; + successMessage = "File is live 📄"; } - - // Append copy notice - successMessage += " (Link copied!)"; + if (limitedDownloads) { + successMessage += ` · self-destructs after ${scanText}`; + } + successMessage += " · link copied"; addToast(successMessage, 3000); diff --git a/islands/AboutModal.tsx b/islands/AboutModal.tsx index b006fe9..2f13d78 100644 --- a/islands/AboutModal.tsx +++ b/islands/AboutModal.tsx @@ -1,10 +1,16 @@ import { useEffect } from "preact/hooks"; import { signal } from "@preact/signals"; -import { useModalShell } from "./modal/useModalShell.ts"; +import { + CardModal, + CardPrimaryButton, + CardSecondaryButton, +} from "./modal/CardModal.tsx"; +import { openPricingModal } from "./PricingModal.tsx"; +import { openKofiModal } from "./KofiModal.tsx"; // Global signal for modal state export const aboutModalOpen = signal(false); -// "intro" = short first-visit poster; "about" = the full tour. +// "intro" = first-visit poster with the three punches; "about" = the manifesto. export const aboutModalMode = signal<"intro" | "about">("about"); export function openAboutModal(mode: "intro" | "about" = "about") { @@ -18,6 +24,13 @@ export function closeAboutModal() { const INTRO_SEEN_KEY = "qrbuddy_intro_seen"; +// One modal at a time (the shell's scroll-lock is single-slot), so hand off +// to the next card after this one has let go. +function handOff(open: () => void) { + closeAboutModal(); + setTimeout(open, 60); +} + export function AboutModal() { const isOpen = aboutModalOpen.value; const isIntro = aboutModalMode.value === "intro"; @@ -35,237 +48,112 @@ export function AboutModal() { } }, []); - const shell = useModalShell({ open: isOpen, onClose: closeAboutModal }); - - if (!shell.mounted) return null; - - // ─── First-visit poster: mascot, rhyme, three punches, one CTA ─── - if (isIntro) { - return ( -