web: the QR card hands out the look that made the photo

A photo's landing section can now be the QR card, and that section is the
only one that hands something out: the server writes the photo's own stored
look back as the app's .recipe file, at
GET /api/photos/:id/preset.recipe, for any row the curator ticked into the
qr slot. Nothing new is stored — the file is built from the recipe the
upload already carried, so it works for a photo uploaded by the phone too.

The admin pane grows a fourth checkbox and a fourth row (QR card); the
row draws the download link as a scannable code, and the box is dead for a
photo with no stored look. The landing's QR card now encodes the curated
photo's own link instead of a mock address. The listing exposes
hasPreset, never the recipe itself.
This commit is contained in:
2026-09-18 16:57:46 +07:00
parent a35ecf4f1c
commit 8a889db069
11 changed files with 233 additions and 18 deletions
+18 -13
View File
@@ -4,6 +4,7 @@ import { useI18n } from './i18n/I18nProvider';
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
import { AuthModal } from './ui/AuthModal';
import { Avatar } from './ui/Avatar';
import { QrCode } from './ui/QrCode';
// Landing page, built to the landing blueprint: the 10-section funnel. The
// marketing prose is bilingual and lives here next to the markup as `Txt`
@@ -160,7 +161,6 @@ export function Landing() {
const [sat, setSat] = useState(100);
const [con, setCon] = useState(100);
const [toast, setToast] = useState<string | null>(null);
const [qrOk, setQrOk] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
// Sign up / log in happens in a dialog on this page, not by a trip to the
@@ -236,11 +236,24 @@ export function Landing() {
// render; the built-in sample stands in while a section has no curation. A
// photo the curator put in several sections can turn up in each of them.
const slotPhoto = useMemo(() => {
const pick = (slot: PhotoSlot, fallback: string) => {
const pick = (slot: PhotoSlot) => {
const list = photos.filter((p) => p.slots.includes(slot));
return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback;
return list.length ? list[Math.floor(Math.random() * list.length)] : null;
};
const src = (slot: PhotoSlot, fallback: string) => {
const p = pick(slot);
return p ? api.photoUrl(p.id) : fallback;
};
// The QR card's payload is the curated photo's own look, as the link the
// phone app downloads it from. The card's mock address stands in while the
// section holds nothing that has a look to hand out.
const qr = pick('qr');
return {
tester: src('tester', SAMPLE(1043)),
creator: src('creator', SAMPLE(1015)),
qr: qr ? api.photoUrl(qr.id) : SAMPLE(1069),
qrLink: qr?.hasPreset ? new URL(api.presetUrl(qr.id), window.location.origin).href : `${window.location.origin}/app`,
};
return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) };
}, [photos]);
// The theme popover closes on a click anywhere else, like the workspace's.
@@ -708,15 +721,7 @@ export function Landing() {
</div>
<div className="lp-rc-row">
<div className="lp-rc-qr">
{qrOk ? (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(`${window.location.origin}/app`)}`}
alt="QR"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setQrOk(false)}
/>
) : null}
<QrCode url={slotPhoto.qrLink} />
</div>
<div className="lp-rc-meta">
<b>SUNSET GLOW</b>