Compare commits
4 Commits
bd1b73e304
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f3adb92c | |||
| 31e4f907f2 | |||
| d02707a212 | |||
| e9b7a2f582 |
@@ -1903,6 +1903,36 @@ one; a second `google-maps` row would collide on that name. To actually
|
||||
use it: `<GoogleMapsEmbed src="<Google Maps embed URL>" title="..." />`
|
||||
anywhere.
|
||||
|
||||
## Short links & QR redirects
|
||||
|
||||
`app/r/[code]/route.ts` and `app/sticker/[code]/route.ts` are static
|
||||
short-link routes (e.g. printed on a flyer or a physical sticker's QR
|
||||
code) that resolve a `code` against Payload's `redirects` collection and
|
||||
307-redirect (`redirect()`, deliberately not `permanentRedirect()` — the
|
||||
target can change at any time and this must never be client-cached) to
|
||||
that doc's `targetPath`. Both routes call the same
|
||||
`resolveAndTrackRedirect(code, urlPrefix)` in `app/lib/payload.ts`, which
|
||||
also fire-and-forget PATCHes `clickCount`/`lastClickedAt` on every hit
|
||||
(tracking failure only logs — never worth stranding a visitor over).
|
||||
|
||||
- **`/r/[code]`** is the default scheme for all new short links —
|
||||
`einfach-produktiv.com/r/aktion-sommer`.
|
||||
- **`/sticker/[code]`** exists only because a batch of physical stickers
|
||||
was already printed as `einfach-produktiv.com/sticker/<code>` (`echt`,
|
||||
`geheimnis`, `fokus`) before `/r/[code]` existed and can't be
|
||||
reprinted — same collection, same lookup, just a second fixed prefix.
|
||||
- The Redirects doc's `urlPrefix` field (`/r` or `/sticker`) isn't just an
|
||||
admin label — `resolveAndTrackRedirect` filters on it too, so a code is
|
||||
only reachable under whichever prefix its doc is actually set to.
|
||||
Changing `urlPrefix` in Payload immediately changes which route serves
|
||||
that code.
|
||||
- Defense in depth: `targetPath` must start with `/` and not `//` (open-
|
||||
redirect guard, same check `app/api/preview/route.ts` does), enforced
|
||||
both in Payload's own field validation and again in each route.ts before
|
||||
calling `redirect()`.
|
||||
- A deactivated (`active: false`) doc — or no matching doc at all —
|
||||
renders a plain 404 (`notFound()`), not a redirect to some fallback.
|
||||
|
||||
## Product image gallery
|
||||
|
||||
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click OR
|
||||
|
||||
+16
-9
@@ -1251,20 +1251,27 @@ export async function getSeoSettings(): Promise<SeoSettings> {
|
||||
};
|
||||
}
|
||||
|
||||
// Powers app/r/[code]/route.ts — a static short link (e.g. printed on a QR
|
||||
// code) that redirects to a `targetPath` editable in Payload at any time,
|
||||
// so the QR code itself never needs reprinting. `cache: "no-store"`
|
||||
// (unlike this file's other public-catalog fetches) since a stale hit here
|
||||
// would send a visitor to a since-changed target, and the PATCH below needs
|
||||
// the just-fetched id/clickCount, not a 60s-old ISR snapshot.
|
||||
// Powers app/r/[code]/route.ts and app/sticker/[code]/route.ts — a static
|
||||
// short link (e.g. printed on a QR code) that redirects to a `targetPath`
|
||||
// editable in Payload at any time, so the QR code itself never needs
|
||||
// reprinting. `cache: "no-store"` (unlike this file's other public-catalog
|
||||
// fetches) since a stale hit here would send a visitor to a since-changed
|
||||
// target, and the PATCH below needs the just-fetched id/clickCount, not a
|
||||
// 60s-old ISR snapshot.
|
||||
type PayloadRedirect = { id: number; targetPath: string; clickCount: number };
|
||||
|
||||
// PATCH failure only logs — click tracking is informational, never worth
|
||||
// stranding a visitor on a broken link over.
|
||||
export async function resolveAndTrackRedirect(code: string): Promise<string | null> {
|
||||
// `urlPrefix` filters the match to whichever fixed frontend route is
|
||||
// actually calling this — Redirects.urlPrefix is otherwise just an admin
|
||||
// label, not something either route enforced, so a code edited to
|
||||
// urlPrefix "/sticker" in Payload would silently keep resolving under
|
||||
// /r/<code> too without this. Each call site passes its own literal
|
||||
// prefix (see route.ts files), so this actually makes the field mean
|
||||
// something rather than only decorate the admin list.
|
||||
export async function resolveAndTrackRedirect(code: string, urlPrefix: "/r" | "/sticker"): Promise<string | null> {
|
||||
const params = new URLSearchParams({
|
||||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||||
"where[code][equals]": code,
|
||||
"where[urlPrefix][equals]": urlPrefix,
|
||||
"where[active][equals]": "true",
|
||||
limit: "1",
|
||||
});
|
||||
|
||||
@@ -81,8 +81,11 @@ export default function NewsletterConfirmedPage() {
|
||||
Beobachte in den nächsten Tagen einfach einmal, womit du deine Aufmerksamkeit
|
||||
verbringst. Nicht bewerten. Nur wahrnehmen.
|
||||
</p>
|
||||
<p>Bis Sonntag</p>
|
||||
<p className="text-text-primary font-semibold">Björn</p>
|
||||
<p>
|
||||
Bis Sonntag
|
||||
<br />
|
||||
<span className="text-text-primary font-semibold">Björn</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -9,7 +9,7 @@ import { resolveAndTrackRedirect } from "../../lib/payload";
|
||||
// non-cacheable client-side since the target can change at any time.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
|
||||
const { code } = await params;
|
||||
const targetPath = await resolveAndTrackRedirect(code);
|
||||
const targetPath = await resolveAndTrackRedirect(code, "/r");
|
||||
|
||||
// Defense in depth — Redirects.targetPath is already validated in Payload
|
||||
// to start with "/", same open-redirect guard as api/preview/route.ts.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { NextRequest } from "next/server";
|
||||
import { resolveAndTrackRedirect } from "../../lib/payload";
|
||||
|
||||
// Legacy sticker QR codes already printed as https://einfach-produktiv.com/sticker/<code>
|
||||
// before the /r/[code] short-link scheme existed — can't be reprinted, so
|
||||
// this mirrors app/r/[code]/route.ts's exact lookup (same Redirects
|
||||
// collection, matched by `code` + urlPrefix "/sticker") under the fixed
|
||||
// /sticker prefix instead. Keeps a single admin-editable/toggleable source
|
||||
// of truth for both.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
|
||||
const { code } = await params;
|
||||
const targetPath = await resolveAndTrackRedirect(code, "/sticker");
|
||||
|
||||
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(targetPath);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
|
||||
// Placeholder landing page for the /sticker/[code] QR redirects (see that
|
||||
// route) — targetPath for those Redirects docs currently points here until
|
||||
// a dedicated campaign page exists; swap targetPath in Payload once it does.
|
||||
export const metadata: Metadata = {
|
||||
title: "Schön, dass du da bist",
|
||||
description: "Du hast einen einfach produktiv Sticker gescannt.",
|
||||
};
|
||||
|
||||
export default function StickerPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-4 items-center text-center pt-24 pb-16 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="flex items-center justify-center size-14 rounded-full bg-brand/10 text-brand shrink-0">
|
||||
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
|
||||
<path d="M9 3.5h6a1.5 1.5 0 0 1 1.5 1.5v14.5L12 16l-4.5 3.5V5A1.5 1.5 0 0 1 9 3.5Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
className="font-semibold text-display text-text-primary"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Schön, dass du da bist!
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h3 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Du hast einen unserer Sticker entdeckt.
|
||||
</p>
|
||||
<div className="h-[0.125rem] w-8 bg-brand" />
|
||||
|
||||
<div className="flex flex-col gap-4 text-left text-body text-text-muted max-w-[28rem] pt-2">
|
||||
<p>Hallo,</p>
|
||||
<p>
|
||||
schön, dass dich der Sticker hierher geführt hat. Schau dich gerne um – hier
|
||||
findest du alles, was einfach produktiv ausmacht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
className="inline-flex items-center justify-center px-7 py-4 mt-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-h4 text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt stöbern
|
||||
</Link>
|
||||
</Reveal>
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user