TikTok V6

This commit is contained in:
2026-07-13 18:51:52 +02:00
parent 5b4a924de9
commit 48e9e2992c
9 changed files with 747 additions and 41 deletions

View File

@@ -0,0 +1,219 @@
# QR Master: Decision-Content + Market-Proof Implementation Plan
> **For Hermes:** Execute only after Timo explicitly asks to implement. Do not create a page cluster before the hub has real search/conversion signals.
**Goal:** Ship one German decision page for the *static vs. dynamic QR code* decision, instrument it with existing analytics, then test the exact message with real DACH restaurants/cafés before expanding SEO content.
**Architecture:** Reuse the existing localized, data-driven use-case route `src/app/de/[slug]/page.tsx` and `UseCasePageTemplate`. Add one German content object to `src/lib/growth-pages-de.ts`; the route already creates static params, self-canonical metadata, hreflang pairs, Breadcrumb/FAQ schema and CTA tracking. Do not touch the English restaurant page: it already owns the restaurant-menu intent at `/restaurants`, and the old English use-case route redirects there.
**Tech Stack:** Next.js App Router, TypeScript, existing `UseCasePageTemplate`, PostHog (`MarketingPageTracker` / `TrackedCtaLink`), npm.
---
## Current repo facts
- German localized use-cases are generated from `src/lib/growth-pages-de.ts:29` through `src/app/de/[slug]/page.tsx:14-108`.
- The route gives each item a self-canonical and creates static params from the data object (`src/app/de/[slug]/page.tsx:14-38`).
- The existing template already tracks page views as `landing_page_viewed` and CTA clicks as `cta_clicked` (`src/components/marketing/MarketingAnalytics.tsx:28-93`). Do **not** create parallel events until the funnels actual setup/signup events are located.
- `/use-cases/restaurant-menu-qr-codes` is a permanent redirect to `/restaurants` (`next.config.mjs:89-92`). The `/restaurants` page already owns English menu/PDF/reprint copy (`src/app/(main)/(marketing)/restaurants/page.tsx:34-69`).
- No German restaurant/speisekarte page exists in `src/lib/growth-pages-de.ts` today.
## Non-goals
- No ten-page pSEO rollout.
- No bespoke template, calculator, schema type, A/B framework, or new tracking architecture.
- No claim of guaranteed savings, GDPR compliance, or “instant” changes unless product behaviour and legal wording are verified.
- No customer-story or dashboard screenshot presented as proof until it is real.
---
### Task 1: Lock the positioning and page boundary
**Objective:** Ensure this page wins a new decision query instead of competing with generic generator or restaurant-menu pages.
**Files:**
- No code change.
- Review: `src/app/(marketing-de)/qr-code-erstellen/page.tsx:26-87`
- Review: `src/app/(main)/(marketing)/restaurants/page.tsx:34-170`
- Review: `next.config.mjs:63-97`
**Step 1: Adopt the exact target user and job**
- Segment: DACH restaurant/café operators with printed table tents, menus, takeaway flyers, or window signs.
- Job: Decide *before printing* whether a QR destination needs to stay editable.
- Primary query family: `statischer oder dynamischer qr code`, `unterschied statischer dynamischer qr code`, `qr code link später ändern`.
- Explicit exclusion: do not target the generic “free QR generator” query or try to become a restaurant POS/menu-builder page.
**Step 2: Define the content angle**
Use this hero content:
- H1: `Statischer oder dynamischer QR-Code? Entscheide vor dem Druck.`
- Intro: `Wenn sich dein Link, Menü, PDF oder Angebot später ändern könnte, brauchst du einen QR-Code, dessen Ziel du ohne Neudruck aktualisieren kannst.`
- Primary CTA: `Dynamischen QR-Code erstellen`
- Secondary CTA: `Zum QR-Code-Generator`
**Step 3: Define the one-sentence decision rule**
`Bleibt das Ziel garantiert unverändert, reicht ein statischer QR-Code. Kann sich Ziel, PDF, Menü, Aktion oder Platzierung ändern, ist ein dynamischer QR-Code die sichere Wahl vor dem Druck.`
**Acceptance criteria:** The page is clearly a decision hub, not another restaurant-menu landing page; it contains the decision within the first viewport.
---
### Task 2: Add one localized use-case record
**Objective:** Publish exactly one data-backed German page at `/de/statischer-vs-dynamischer-qr-code` through the current route/template.
**Files:**
- Modify: `src/lib/growth-pages-de.ts` (inside `useCasePagesDe`, before the closing object)
**Step 1: Add the record shape**
Add a `UseCasePageContentDe` record with:
```ts
'statischer-vs-dynamischer-qr-code': {
enSlug: 'dynamic-qr-code-generator',
slug: 'statischer-vs-dynamischer-qr-code',
href: '/de/statischer-vs-dynamischer-qr-code',
title: 'Statischer oder dynamischer QR-Code?',
cluster: 'qr-code-basics',
parentHref: '/dynamic-qr-code-generator',
parentTitle: 'Dynamischer QR-Code-Generator',
ctaLabel: 'Dynamischen QR-Code erstellen',
eyebrow: 'Vor dem Druck entscheiden',
titleSuffix: 'vor dem Druck',
metaDescription: 'Statischer oder dynamischer QR-Code? Vergleiche Änderbarkeit, Druckrisiko und Tracking und entscheide vor Flyer, Speisekarte oder Tischaufsteller.',
// remaining template fields in the following steps
}
```
**Step 2: Write unique decision content—not token substitutions**
Populate the template fields with these content requirements:
- `answer`: the exact decision rule from Task 1.
- `whenToUse`: three observable conditions: destination cannot change; a PDF/menu/offer might change; scans must be measured by placement.
- `comparisonItems`: only concrete trade-offs, e.g. destination after print, response to a changed PDF, scan measurement. Validate the templates left/right presentation before wording the boolean values.
- `howToSteps`: create a dynamic code → print it once → change the destination later in the dashboard.
- `workflowCards`: one realistic café example (30 table tents, new menu PDF), one flyer/event example, one separate-placement tracking example.
- `checklist`: test print size/contrast, use a descriptive scan CTA, point to a mobile target, keep a dynamic target when it can change.
- `supportLinks`: `/dynamic-qr-code-generator`, `/qr-code-tracking`, `/reprint-calculator`, `/qr-code-print-size-guide` after verifying every target resolves.
- `faq`: 34 factual FAQs such as “Kann ich den Link eines statischen QR-Codes später ändern?” and “Wann lohnt sich ein dynamischer QR-Code für eine Speisekarte?”
**Step 3: Avoid fictional proof**
Set no `heroImage` unless a real product/dashboard or verified purpose-built illustration exists. If the template needs an image visually, use a clearly labelled product workflow asset—not a fake customer outcome or fabricated dashboard state.
**Acceptance criteria:** `generateStaticParams` includes the slug; the page has unique German title, description, H1, intro, FAQ and internal-link context.
---
### Task 3: Verify rendered SEO and conversion path
**Objective:** Confirm the new route is indexable, non-cannibalizing, and sends users into a working generator flow.
**Files:**
- Verify: `src/app/de/[slug]/page.tsx:18-52`
- Verify: `src/components/marketing/UseCasePageTemplate.tsx:440-526`
- Verify: `src/app/sitemap.ts`
**Step 1: Run static checks**
Run from repository root:
```bash
npm run lint
npm run build
```
Expected: both commands exit `0`.
**Step 2: Run the local smoke test**
Start the app with `npm run dev`, then inspect:
- `http://localhost:3050/de/statischer-vs-dynamischer-qr-code`
- page source/rendered HTML contains one H1
- canonical is `https://www.qrmaster.net/de/statischer-vs-dynamischer-qr-code`
- page has language alternates generated by `buildLanguageAlternates`
- primary CTA reaches the German setup/generator flow actually intended for conversion
- all related-resource URLs return a valid page (not redirects to irrelevant pages or 404)
- mobile: H1, decision rule, and primary CTA fit/appear before excessive scrolling; comparison is readable
**Step 3: Verify tracking rather than inventing event names**
Use the existing PostHog events first:
- `landing_page_viewed` with `landing_page_slug=/de/statischer-vs-dynamischer-qr-code`
- `cta_clicked` with `cta_location=hero_primary` and this use case slug
Before adding `qr_setup_started` or `signup_completed`, locate their real implementation. If no downstream events exist, add them only after documenting the actual generator/signup handoff and preserving no-PII tracking.
**Acceptance criteria:** build passes, canonical and CTA are correct, events appear in PostHog (or the tracking limitation is explicitly documented), and there is no new page aimed at `/restaurants` restaurant-menu keyword set.
---
### Task 4: Run a 7-day manual market test in parallel
**Objective:** Test whether the language describes a costly current problem, not merely whether people say they like the page.
**Files:**
- No product code required.
- Create a local working sheet only if Timo asks; do not automate outreach first.
**Step 1: Build a 20-prospect list (45 minutes max)**
Collect 20 independent restaurants/cafés in one local area. Criteria:
- visible printed menu/table-tent/window QR in Google photos, Instagram, or own site;
- a current menu PDF, ordering page, seasonal offer, or booking link;
- reachable email, Instagram, or contact form.
**Step 2: Send 10 short research-first messages**
Use this wording, adapted with the venues name and observed QR use:
> Hi [Name], kurze Frage ich schaue mir gerade an, wie Restaurants QR-Codes auf Tischaufstellern und Speisekarten nutzen. Musstet ihr den Link, die PDF-Speisekarte oder Preise nach dem Druck schon einmal ändern? Was habt ihr dann gemacht? Ich verkaufe dir gerade nichts; ich will den Ablauf verstehen.
Do not pitch QR Master in the first message.
**Step 3: Follow up only with a relevant offer**
If they mention an actual recent issue, reply:
> Danke, genau diesen Fall untersuche ich. Ich baue ein Setup, bei dem der gedruckte QR-Code bleibt und du nur das Ziel aktualisierst. Wenn du willst, richte ich dir den nächsten Code testweise ein und prüfe vorher kostenlos, ob euer aktueller Code überhaupt update-sicher ist.
**Step 4: Record proof, not compliments**
For every reply capture: last incident, workaround, print/time cost, current QR tool, decision-maker, and whether they agree to a test/pilot. Strong evidence is a real past case, a follow-up call, a current QR audit, or a pilot—not “klingt gut”.
**Acceptance criteria:** at least 10 contacts sent and one of these evidence types captured: concrete past incident, audit request, pilot, call, trial, or explicit rejection with reason.
---
### Task 5: Make the expansion/kill decision after 714 days
**Objective:** Prevent a content factory before a signal exists.
**Decision table:**
| Signal | Decision |
| --- | --- |
| Search impressions/query relevance + CTA clicks | Build exactly one follow-up: `/de/qr-code-nach-druck-aendern` focused on recovery intent. |
| At least 2 concrete restaurant incidents or 1 pilot | Improve the restaurant-specific proof/CTA or ship a German restaurant page only after title/canonical mapping against `/restaurants`. |
| No relevant queries, replies, CTA clicks, or incidents | Do not create more pages. Change segment/message (e.g. flyers/events/real-estate) and repeat outreach. |
**Do not expand to** flyer, business-card, event, packaging, PDF, and restaurant subpages simultaneously. One successful page/message earns one sibling.
---
## Final Definition of Done
1. One live, unique German decision page—not a page cluster.
2. `npm run lint` and `npm run build` pass.
3. Canonical, hreflang, CTA and tracking are verified on the rendered route.
4. Ten manual research contacts are sent.
5. A seven-day review contains real proof: Search Console query data, PostHog CTA data, replies, calls, audits, trials, or payments.
6. Only then decide whether `/de/qr-code-nach-druck-aendern` deserves implementation.

View File

@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" role="img" aria-labelledby="title desc">
<title id="title">Statischer und dynamischer QR-Code im Vergleich</title>
<desc id="desc">Illustration: Ein statischer QR-Code enthält einen festen Link. Bei einem dynamischen QR-Code kann das Ziel später geändert werden.</desc>
<defs>
<linearGradient id="background" x1="0" x2="1" y1="0" y2="1">
<stop stop-color="#f8fafc"/>
<stop offset="1" stop-color="#eef4ff"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="16" stdDeviation="18" flood-color="#0f172a" flood-opacity=".10"/>
</filter>
</defs>
<rect width="1200" height="900" rx="36" fill="url(#background)"/>
<text x="72" y="82" fill="#64748b" font-family="Arial, sans-serif" font-size="24" font-weight="700" letter-spacing="3">ILLUSTRATION</text>
<text x="72" y="132" fill="#0f172a" font-family="Arial, sans-serif" font-size="38" font-weight="700">Vor dem Druck entscheiden</text>
<g filter="url(#shadow)">
<rect x="72" y="184" width="480" height="590" rx="28" fill="#fff" stroke="#e2e8f0" stroke-width="3"/>
<rect x="648" y="184" width="480" height="590" rx="28" fill="#fff" stroke="#bfdbfe" stroke-width="3"/>
</g>
<g font-family="Arial, sans-serif">
<rect x="108" y="222" width="196" height="44" rx="22" fill="#fef2f2"/>
<text x="132" y="251" fill="#b91c1c" font-size="20" font-weight="700">FESTER LINK</text>
<text x="108" y="318" fill="#0f172a" font-size="34" font-weight="700">Statischer QR-Code</text>
<text x="108" y="354" fill="#64748b" font-size="22">Link fest eingebrannt</text>
<rect x="684" y="222" width="226" height="44" rx="22" fill="#eff6ff"/>
<text x="708" y="251" fill="#2563eb" font-size="20" font-weight="700">FLEXIBLES ZIEL</text>
<text x="684" y="318" fill="#0f172a" font-size="34" font-weight="700">Dynamischer QR-Code</text>
<text x="684" y="354" fill="#64748b" font-size="22">Ziel später ändern</text>
</g>
<g transform="translate(116 410)">
<rect width="190" height="190" rx="18" fill="#fff" stroke="#0f172a" stroke-width="14"/>
<rect x="24" y="24" width="42" height="42" fill="#0f172a"/><rect x="124" y="24" width="42" height="42" fill="#0f172a"/>
<rect x="24" y="124" width="42" height="42" fill="#0f172a"/>
<path d="M84 24h18v18H84zm18 36h18v18h-18zm-18 36h18v18H84zm36 18h18v18h-18zm-18 36h18v18h-18zm36 0h28v18h-28zM84 132h18v18H84zM120 96h18v18h-18z" fill="#0f172a"/>
</g>
<path d="M340 504h132" stroke="#cbd5e1" stroke-width="8" stroke-linecap="round"/>
<path d="M448 480l28 24-28 24" fill="none" stroke="#cbd5e1" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="336" y="444" width="160" height="116" rx="16" fill="#f8fafc" stroke="#e2e8f0" stroke-width="2"/>
<text x="360" y="485" fill="#64748b" font-family="Arial, sans-serif" font-size="17" font-weight="700">ZIEL-URL</text>
<text x="360" y="520" fill="#b91c1c" font-family="Arial, sans-serif" font-size="17">nicht änderbar</text>
<g transform="translate(692 410)">
<rect width="190" height="190" rx="18" fill="#fff" stroke="#0f172a" stroke-width="14"/>
<rect x="24" y="24" width="42" height="42" fill="#0f172a"/><rect x="124" y="24" width="42" height="42" fill="#0f172a"/>
<rect x="24" y="124" width="42" height="42" fill="#0f172a"/>
<path d="M84 24h18v18H84zm18 36h18v18h-18zm-18 36h18v18H84zm36 18h18v18h-18zm-18 36h18v18h-18zm36 0h28v18h-28zM84 132h18v18H84zM120 96h18v18h-18z" fill="#0f172a"/>
</g>
<path d="M916 504h100" stroke="#93c5fd" stroke-width="8" stroke-linecap="round"/>
<path d="M992 480l28 24-28 24" fill="none" stroke="#2563eb" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="912" y="426" width="176" height="156" rx="16" fill="#eff6ff" stroke="#bfdbfe" stroke-width="2"/>
<text x="936" y="464" fill="#2563eb" font-family="Arial, sans-serif" font-size="17" font-weight="700">DASHBOARD</text>
<rect x="936" y="485" width="126" height="12" rx="6" fill="#93c5fd"/>
<rect x="936" y="515" width="96" height="12" rx="6" fill="#bfdbfe"/>
<rect x="936" y="543" width="88" height="12" rx="6" fill="#2563eb"/>
<g font-family="Arial, sans-serif">
<circle cx="128" cy="684" r="13" fill="#ef4444"/>
<text x="156" y="691" fill="#475569" font-size="21">Neuer Link? Neuer QR-Code nötig.</text>
<circle cx="704" cy="684" r="13" fill="#22c55e"/>
<text x="732" y="691" fill="#475569" font-size="21">Neuer Link? Ziel im Dashboard ändern.</text>
</g>
<path d="M552 446h96" stroke="#94a3b8" stroke-width="4" stroke-dasharray="10 12"/>
<circle cx="600" cy="446" r="28" fill="#fff" stroke="#cbd5e1" stroke-width="3"/>
<text x="586" y="455" fill="#475569" font-family="Arial, sans-serif" font-size="24" font-weight="700">vs.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,5 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { getValidTiktokTokens, getLiveTiktokAccessToken, tiktokApi, uploadBinaryToTiktok } from '@/lib/tiktok';
import {
getValidTiktokTokens,
tiktokApi,
uploadBinaryToTiktok,
planTiktokChunks,
sniffVideoMimeType,
pollTiktokPublishStatus,
} from '@/lib/tiktok';
const isAdminRequest = (request: NextRequest) => {
const adminKey = process.env.TIKTOK_ADMIN_KEY;
@@ -33,12 +40,29 @@ export async function POST(request: NextRequest) {
if (typeof json?.videoBufferBase64 === 'string') {
const videoBuffer = Buffer.from(json.videoBufferBase64, 'base64');
const videoSize = videoBuffer.length;
if (!videoSize) {
return NextResponse.json(
{ error: 'videoBufferBase64 decoded to an empty file.' },
{ status: 400 }
);
}
// Validate by magic bytes, not by a client-supplied MIME string.
const mimeType = sniffVideoMimeType(videoBuffer);
if (!mimeType) {
return NextResponse.json(
{ error: 'Video must be MP4, MOV, or WebM (magic-byte check failed).' },
{ status: 400 }
);
}
const { chunkSize, totalChunkCount } = planTiktokChunks(videoSize);
const initBody = {
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: videoSize,
total_chunk_count: 1,
chunk_size: chunkSize,
total_chunk_count: totalChunkCount,
},
};
@@ -61,7 +85,7 @@ export async function POST(request: NextRequest) {
);
}
await uploadBinaryToTiktok(uploadUrl, videoBuffer, 'video/mp4');
await uploadBinaryToTiktok(uploadUrl, videoBuffer, mimeType);
} else if (Array.isArray(json?.photos)) {
const photos = json.photos as Array<{ url?: string } | string>;
if (!photos.length) {
@@ -94,6 +118,18 @@ export async function POST(request: NextRequest) {
const title = typeof json?.title === 'string' ? json.title.trim() : '';
const description = typeof json?.description === 'string' ? json.description.trim() : '';
if (title.length > 90) {
return NextResponse.json(
{ error: 'title must be at most 90 characters for TikTok photo posts.' },
{ status: 400 }
);
}
if (description.length > 4000) {
return NextResponse.json(
{ error: 'description must be at most 4000 characters for TikTok photo posts.' },
{ status: 400 }
);
}
const initBody = {
media_type: 'PHOTO',
@@ -143,9 +179,16 @@ export async function POST(request: NextRequest) {
);
}
// INITIATED is not delivery. Poll until the draft reaches the creator's
// inbox (SEND_TO_USER_INBOX) or fails, bounded so the request finishes.
const finalStatus = await pollTiktokPublishStatus(publishId, { maxWaitMs: 45_000 });
status = finalStatus.status || status;
return NextResponse.json({
publish_id: publishId,
status,
delivered: status === 'SEND_TO_USER_INBOX',
...(finalStatus.failReason ? { fail_reason: finalStatus.failReason } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';

View File

@@ -23,7 +23,7 @@ export function generateMetadata({ params }: { params: { slug: string } }): Meta
}
const alternates = buildLanguageAlternates({
en: `/use-cases/${page.enSlug}`,
en: page.enPath ?? `/use-cases/${page.enSlug}`,
de: page.href,
});
@@ -101,6 +101,7 @@ export default function UseCaseDetailPageDe({
heroImageAlt={page.heroImageAlt}
authoritySignals={page.authoritySignals}
directAnswer={page.directAnswer}
enableScrollReveal={page.enableScrollReveal}
labels={{
...useCaseTemplateLabelsDe,
faqTitle: `FAQ: ${page.title}`,

View File

@@ -19,22 +19,41 @@ interface AnswerFirstBlockProps {
howTo: {
steps: string[]; // 3 Steps: "So funktioniert's"
};
labels?: {
summaryTitle: string;
whenToUseTitle: string;
comparisonTitle: string;
howItWorksTitle: string;
supportedLabel: string;
unavailableLabel: string;
};
className?: string; // Add className prop
}
const defaultLabels = {
summaryTitle: 'Quick Summary',
whenToUseTitle: 'When to use this?',
comparisonTitle: 'Comparison',
howItWorksTitle: 'How it works',
supportedLabel: 'Supported',
unavailableLabel: 'No',
};
export const AnswerFirstBlock: React.FC<AnswerFirstBlockProps> = ({
whatIsIt,
whenToUse,
comparison,
howTo,
labels: customLabels,
className,
}) => {
const labels = customLabels ?? defaultLabels;
const leftValueFor = (item: ComparisonItem) => item.text ?? 'No';
return (
<section className={`my-12 space-y-10 ${className || ''}`} aria-label="Quick answer">
<div className="max-w-4xl space-y-4">
<h2 className="text-2xl font-semibold text-slate-900 tracking-tight -tracking-[0.02em]">Quick Summary</h2>
<h2 className="text-2xl font-semibold text-slate-900 tracking-tight -tracking-[0.02em]">{labels.summaryTitle}</h2>
<p className="text-lg text-slate-600 leading-relaxed font-light">
{whatIsIt}
</p>
@@ -45,7 +64,7 @@ export const AnswerFirstBlock: React.FC<AnswerFirstBlockProps> = ({
<Card className="p-6 bg-white border-slate-200/60 shadow-sm rounded-xl flex flex-col h-full">
<h3 className="font-semibold text-lg text-slate-900 mb-5 flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-slate-400 shrink-0" />
When to use this?
{labels.whenToUseTitle}
</h3>
<ul className="space-y-4 flex-1">
{whenToUse.map((item, idx) => (
@@ -61,7 +80,7 @@ export const AnswerFirstBlock: React.FC<AnswerFirstBlockProps> = ({
<Card className="p-6 bg-white border-slate-200/60 shadow-sm rounded-xl flex flex-col h-full">
<h3 className="font-semibold text-lg text-slate-900 mb-5 flex items-center gap-2">
<ArrowRight className="w-5 h-5 text-slate-400 shrink-0 rotate-[-45deg]" />
Comparison
{labels.comparisonTitle}
</h3>
<div className="space-y-3.5 flex-1">
{comparison.items.map((item, idx) => (
@@ -82,12 +101,12 @@ export const AnswerFirstBlock: React.FC<AnswerFirstBlockProps> = ({
{item.value ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-600 shrink-0" aria-hidden="true" />
<span className="text-indigo-650">Supported</span>
<span className="text-indigo-650">{labels.supportedLabel}</span>
</>
) : (
<>
<X className="w-3.5 h-3.5 text-red-400 shrink-0" aria-hidden="true" />
<span className="text-slate-500">No</span>
<span className="text-slate-500">{labels.unavailableLabel}</span>
</>
)}
</div>
@@ -102,7 +121,7 @@ export const AnswerFirstBlock: React.FC<AnswerFirstBlockProps> = ({
<Card className="p-6 bg-white border-slate-200/60 shadow-sm rounded-xl flex flex-col h-full">
<h3 className="font-semibold text-lg text-slate-900 mb-5 flex items-center gap-2">
<Play className="w-5 h-5 text-slate-400 shrink-0" />
How it works
{labels.howItWorksTitle}
</h3>
<ol className="space-y-6 flex-1">
{howTo.steps.map((step, idx) => (

View File

@@ -0,0 +1,35 @@
'use client';
import type { ReactNode } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
type ScrollRevealProps = {
children: ReactNode;
delay?: number;
className?: string;
};
/**
* A small, optional entrance animation for content that benefits from a
* progressive reading order. Reduced-motion users receive the final state
* immediately.
*/
export function ScrollReveal({
children,
delay = 0,
className,
}: ScrollRevealProps) {
const prefersReducedMotion = useReducedMotion();
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.01 }}
transition={{ duration: 0.4, delay, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
);
}

View File

@@ -1,6 +1,7 @@
import type { FAQItem } from "@/lib/types";
import type { Metadata } from "next";
import dynamic from "next/dynamic";
import Image from "next/image";
import Link from "next/link";
import {
@@ -32,6 +33,10 @@ import { Card } from "@/components/ui/Card";
import { HeroSpotlight } from "@/components/marketing/HeroSpotlight";
import { breadcrumbSchema, faqPageSchema } from "@/lib/schema";
const ScrollReveal = dynamic(() =>
import("@/components/marketing/ScrollReveal").then((module) => module.ScrollReveal),
);
type LinkCard = {
href: string;
title: string;
@@ -50,6 +55,14 @@ export type UseCaseTemplateLabels = {
faqTitle?: string;
nextStepEyebrow: string;
ctaBannerTitle: string;
answerFirst?: {
summaryTitle: string;
whenToUseTitle: string;
comparisonTitle: string;
howItWorksTitle: string;
supportedLabel: string;
unavailableLabel: string;
};
};
const defaultLabels: UseCaseTemplateLabels = {
@@ -68,6 +81,14 @@ const defaultLabels: UseCaseTemplateLabels = {
relatedResourcesTitle: "Related resources",
nextStepEyebrow: "Next step",
ctaBannerTitle: "Start with a QR setup that keeps your print permanent.",
answerFirst: {
summaryTitle: "Quick Summary",
whenToUseTitle: "When to use this?",
comparisonTitle: "Comparison",
howItWorksTitle: "How it works",
supportedLabel: "Supported",
unavailableLabel: "No",
},
};
const subBenefitIcons = [ShieldCheck, RefreshCw, BarChart3, Zap];
@@ -112,6 +133,7 @@ type UseCasePageTemplateProps = {
authoritySignals?: string[];
directAnswer?: string;
schemaData?: Record<string, unknown>[];
enableScrollReveal?: boolean;
labels?: UseCaseTemplateLabels;
};
@@ -433,6 +455,7 @@ export function UseCasePageTemplate({
authoritySignals = [],
directAnswer,
schemaData = [],
enableScrollReveal = false,
labels = defaultLabels,
}: UseCasePageTemplateProps) {
return (
@@ -566,6 +589,8 @@ export function UseCasePageTemplate({
{/* AnswerFirst block - summary and quick details */}
<div className="container mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
{enableScrollReveal ? (
<ScrollReveal>
<AnswerFirstBlock
whatIsIt={answer}
whenToUse={whenToUse}
@@ -577,8 +602,26 @@ export function UseCasePageTemplate({
howTo={{
steps: howToSteps,
}}
labels={labels.answerFirst}
className="mt-0"
/>
</ScrollReveal>
) : (
<AnswerFirstBlock
whatIsIt={answer}
whenToUse={whenToUse}
comparison={{
leftTitle: labels.comparisonLeftTitle,
rightTitle: labels.comparisonRightTitle,
items: comparisonItems,
}}
howTo={{
steps: howToSteps,
}}
labels={labels.answerFirst}
className="mt-0"
/>
)}
</div>
{directAnswer ? (
@@ -617,9 +660,8 @@ export function UseCasePageTemplate({
<div className="grid gap-8 lg:grid-cols-3">
{workflowCards.map((card, idx) => {
const Icon = getWorkflowIcon(idx);
return (
const cardContent = (
<Card
key={card.title}
className="rounded-xl border-slate-200 bg-white p-8 shadow-sm transition-all duration-250 hover:border-slate-300 hover:shadow-md"
>
<div className="mb-6 flex h-11 w-11 items-center justify-center rounded-lg bg-slate-50 border border-slate-100 text-slate-655">
@@ -633,6 +675,14 @@ export function UseCasePageTemplate({
</p>
</Card>
);
return enableScrollReveal ? (
<ScrollReveal key={card.title} delay={idx * 0.08}>
{cardContent}
</ScrollReveal>
) : (
<div key={card.title}>{cardContent}</div>
);
})}
</div>
</div>
@@ -718,7 +768,7 @@ export function UseCasePageTemplate({
<div className="container mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div className="relative rounded-2xl bg-slate-50 border border-slate-200 p-8 sm:p-12 text-slate-900 shadow-sm overflow-hidden">
<div className="relative z-10 flex flex-col gap-8 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-2xl space-y-3">
<div className="max-w-2xl min-w-0 flex-1 space-y-3">
<div className="text-xs font-semibold uppercase tracking-wider text-slate-450">
{labels.nextStepEyebrow}
</div>
@@ -730,7 +780,7 @@ export function UseCasePageTemplate({
</p>
</div>
<div className="flex flex-col gap-3.5 sm:flex-row shrink-0">
<div className="flex flex-col gap-3.5 shrink-0">
<TrackedCtaLink
href={primaryCta.href}
ctaLabel={primaryCta.label}

View File

@@ -6,6 +6,10 @@ import type { UseCaseTemplateLabels } from '@/components/marketing/UseCasePageTe
// for hreflang pairing.
export type UseCasePageContentDe = UseCasePageContent & {
enSlug: string;
// Most German use cases map to an English /use-cases/ page. A small number
// pair with a more specific English editorial page instead.
enPath?: string;
enableScrollReveal?: boolean;
};
export const useCaseTemplateLabelsDe: UseCaseTemplateLabels = {
@@ -24,9 +28,140 @@ export const useCaseTemplateLabelsDe: UseCaseTemplateLabels = {
relatedResourcesTitle: 'Weiterführende Seiten',
nextStepEyebrow: 'Nächster Schritt',
ctaBannerTitle: 'Starten Sie mit einem QR-Setup, bei dem Ihr Druck dauerhaft gültig bleibt.',
answerFirst: {
summaryTitle: 'Kurz erklärt',
whenToUseTitle: 'Wann ist das sinnvoll?',
comparisonTitle: 'Vergleich',
howItWorksTitle: 'So funktioniert es',
supportedLabel: 'Möglich',
unavailableLabel: 'Nicht möglich',
},
};
export const useCasePagesDe: Record<string, UseCasePageContentDe> = {
'statischer-vs-dynamischer-qr-code': {
enSlug: 'static-vs-dynamic-qr-code',
enPath: '/blog/static-vs-dynamic-qr-code',
enableScrollReveal: true,
slug: 'statischer-vs-dynamischer-qr-code',
href: '/de/statischer-vs-dynamischer-qr-code',
title: 'Statischer oder dynamischer QR-Code?',
cluster: 'qr-code-basics',
summary:
'Entscheiden Sie vor dem Druck, ob Ihr QR-Ziel dauerhaft unverändert bleibt oder später ohne Neudruck aktualisiert werden muss.',
parentHref: '/dynamic-qr-code-generator',
parentTitle: 'Dynamischer QR-Code-Generator',
ctaLabel: 'Dynamischen QR-Code erstellen',
eyebrow: 'Vor dem Druck entscheiden',
titleSuffix: 'vor dem Druck',
metaDescription:
'Statischer oder dynamischer QR-Code? Vergleichen Sie Änderbarkeit, Druckrisiko und Tracking und entscheiden Sie vor Flyer, Speisekarte oder Tischaufsteller.',
intro:
'Bleibt Ihr Link garantiert unverändert, reicht ein statischer QR-Code. Können sich PDF, Menü, Angebot oder Zielseite später ändern, bleibt ein dynamischer QR-Code nach dem Druck flexibel.',
answer:
'Ein statischer QR-Code enthält sein Ziel direkt und lässt sich nach dem Druck nicht ändern. Ein dynamischer QR-Code führt über ein verwaltbares Ziel: Sie können die Zielseite später aktualisieren, während der gedruckte Code bestehen bleibt.',
whenToUse: [
'Das Ziel bleibt dauerhaft gleich, etwa bei einer unveränderlichen PDF-Datei oder einer festen Kontaktinformation.',
'Ein Menü, Preis, Angebot, PDF oder Link kann sich ändern, nachdem Flyer, Karten oder Aufsteller gedruckt sind.',
'Sie möchten Scans von Tischen, Fenstern, Flyern oder anderen Platzierungen getrennt auswerten.',
],
comparisonItems: [
{ label: 'Ziel nach dem Druck ändern', text: 'Nicht möglich', value: true },
{ label: 'Neues PDF oder Angebot verlinken', text: 'Neudruck nötig', value: true },
{ label: 'Scans nach Platzierung messen', text: 'Nicht verfügbar', value: true },
],
howToSteps: [
'Erstellen Sie einen dynamischen QR-Code mit Ihrem aktuellen Link, Menü oder PDF.',
'Testen Sie den Scan und drucken Sie denselben Code auf Flyer, Speisekarten oder Tischaufsteller.',
'Ändern Sie später das Ziel im Dashboard, statt den gedruckten QR-Code zu ersetzen.',
],
workflowTitle: 'Wann ein dynamischer QR-Code die bessere Entscheidung ist',
workflowIntro:
'Die wichtige Frage ist nicht, ob ein QR-Code heute funktioniert. Entscheidend ist, ob Ihr gedrucktes Material noch funktioniert, wenn sich dahinter etwas ändert.',
workflowCards: [
{
title: 'Speisekarte oder PDF aktualisieren',
description:
'Ein Café druckt Tischaufsteller mit QR-Code zur Speisekarte. Ändern sich Preise oder die PDF-Datei, aktualisiert das Team nur das Ziel statt alle Aufsteller neu zu bestellen.',
},
{
title: 'Flyer und Aktionen flexibel halten',
description:
'Ein gedruckter Aktionsflyer bleibt im Umlauf, auch wenn sich Landingpage, Saisonangebot oder Termin ändern. Der Code führt weiterhin zur aktuellen Aktion.',
},
{
title: 'Platzierungen getrennt verstehen',
description:
'Mit eigenen dynamischen Codes für Fenster, Tische und Take-away-Flyer erkennen Sie, welche gedruckte Platzierung tatsächlich Scans auslöst.',
},
],
checklistTitle: 'QR-Code vor dem Druck prüfen',
checklist: [
'Wählen Sie einen statischen Code nur, wenn das Ziel wirklich nicht geändert werden muss.',
'Nutzen Sie einen dynamischen Code für wechselnde Menüs, PDFs, Angebote und Kampagnen.',
'Testen Sie Größe, Kontrast und Scan-Distanz mit einem echten Smartphone vor der Druckfreigabe.',
"Ergänzen Sie einen klaren Hinweis wie 'Scannen für die aktuelle Speisekarte'.",
],
supportLinks: [
{
href: '/dynamic-qr-code-generator',
title: 'Dynamischer QR-Code-Generator',
description:
'Erstellen Sie einen QR-Code, dessen Ziel Sie später verwalten können.',
},
{
href: '/qr-code-tracking',
title: 'QR-Code-Tracking',
description:
'Messen Sie Scans nach Zeit, Gerät und gedruckter Platzierung.',
},
{
href: '/reprint-calculator',
title: 'Neudruck-Rechner',
description:
'Vergleichen Sie den Aufwand wiederkehrender Neudrucke mit einem aktualisierbaren QR-Ziel.',
},
{
href: '/qr-code-print-size-guide',
title: 'QR-Code-Druckgrößen-Guide',
description:
'Prüfen Sie die passende QR-Größe, bevor Ihr Material in den Druck geht.',
},
],
faq: [
{
question: 'Kann ich einen QR-Code nach dem Druck noch ändern?',
answer:
'Bei einem statischen QR-Code nicht: Das Ziel ist direkt im Code gespeichert. Ist der Link nach dem Druck falsch oder veraltet, brauchen Sie einen neuen QR-Code und in der Regel einen Neudruck. Bei einem dynamischen QR-Code ändern Sie das Ziel im Dashboard, während der gedruckte Code bestehen bleibt.',
},
{
question: 'Wann brauche ich einen dynamischen QR-Code?',
answer:
'Nutzen Sie einen dynamischen QR-Code, wenn sich Zielseite, Menü, PDF, Angebot oder Kampagne später ändern kann oder wenn Sie Scan-Daten auswerten möchten.',
},
{
question: 'Kann ich ein Menü-PDF hinter einem dynamischen QR-Code austauschen?',
answer:
'Ja. Sie aktualisieren das Ziel oder die verknüpfte Datei im Dashboard. Der bereits gedruckte dynamische QR-Code kann weiter verwendet werden.',
},
{
question: 'Funktioniert ein dynamischer QR-Code auch auf Flyern und Tischaufstellern?',
answer:
'Ja. Gerade bei gedruckten Materialien mit wechselnden Angeboten, Links oder Inhalten ist ein dynamisches Ziel sinnvoll, weil der Code physisch unverändert bleiben kann.',
},
],
heroImage: '/images/static-vs-dynamic-qr-de.svg',
heroImageAlt:
'Illustration: Statischer QR-Code mit festem Link im Vergleich zu einem dynamischen QR-Code mit später änderbarem Ziel',
directAnswer:
'Kurz gesagt: Ein statischer QR-Code ist sinnvoll, wenn sich das Ziel garantiert nie ändert. Für alles Gedruckte, das länger im Einsatz bleibt oder auf wechselnde Inhalte verweist, reduziert ein dynamischer QR-Code das Risiko eines veralteten Links.',
authoritySignals: [
'Klarer Vergleich vor der Druckfreigabe statt Technik-Jargon.',
'Konkrete Beispiele für Menüs, PDFs, Flyer und Tischaufsteller.',
'Keine pauschalen Einsparversprechen prüfen Sie Ihren eigenen Neudruck-Aufwand.',
],
},
'qr-codes-fuer-fitnessstudios': {
enSlug: 'qr-codes-for-gyms',
slug: 'qr-codes-fuer-fitnessstudios',

View File

@@ -192,20 +192,153 @@ export async function tiktokApi(url: string, options: RequestInit = {}) {
return data;
}
export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
const res = await fetch(uploadUrl, {
// ─── Binary upload (Content-Range, chunking, timeout, retries) ─────────────
const UPLOAD_CHUNK_TIMEOUT_MS = 120_000;
const MAX_CHUNK_ATTEMPTS = 3;
// TikTok requires chunks between 5 MB and 64 MB; only a file that fits in a
// single chunk may be smaller than 5 MB.
export const TIKTOK_MAX_CHUNK_BYTES = 64 * 1024 * 1024;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function retryDelayMs(res: Response | null, attempt: number) {
const retryAfter = res?.headers.get('retry-after');
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 30_000);
}
return Math.min(1000 * 2 ** attempt, 15_000) + Math.floor(Math.random() * 500);
}
// TikTok expects total_chunk_count = floor(video_size / chunk_size); any
// remainder is merged into the final chunk.
export function planTiktokChunks(totalBytes: number) {
if (totalBytes <= TIKTOK_MAX_CHUNK_BYTES) {
return { chunkSize: totalBytes, totalChunkCount: 1 };
}
const chunkSize = TIKTOK_MAX_CHUNK_BYTES;
return { chunkSize, totalChunkCount: Math.floor(totalBytes / chunkSize) };
}
export function sniffVideoMimeType(buffer: Buffer): 'video/mp4' | 'video/quicktime' | 'video/webm' | null {
if (buffer.length >= 12 && buffer.toString('ascii', 4, 8) === 'ftyp') {
const brand = buffer.toString('ascii', 8, 12);
return brand.startsWith('qt') ? 'video/quicktime' : 'video/mp4';
}
if (buffer.length >= 4 && buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
return 'video/webm';
}
return null;
}
async function putTiktokChunk(
uploadUrl: string,
chunk: Buffer,
opts: { start: number; end: number; total: number; mimeType: string; isLast: boolean }
) {
let lastError: TiktokApiError | null = null;
for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), UPLOAD_CHUNK_TIMEOUT_MS);
let res: Response | null = null;
try {
res = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': mimeType,
'Content-Length': String(buffer.length),
'Content-Type': opts.mimeType,
'Content-Length': String(chunk.length),
'Content-Range': `bytes ${opts.start}-${opts.end}/${opts.total}`,
},
body: new Uint8Array(buffer),
body: new Uint8Array(chunk),
signal: controller.signal,
});
const text = await res.text();
if (!res.ok && res.status !== 201) {
throw new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
// Complete upload → 201; intermediate chunk → 206. Anything else is an
// error even when it is 2xx, so a silently incomplete upload never
// counts as success.
const expected = opts.isLast ? 201 : 206;
if (res.status === expected) {
return { status: res.status, body: text };
}
lastError = new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
const retryable = res.status === 429 || res.status >= 500;
if (!retryable) throw lastError;
} catch (err) {
if (err instanceof TiktokApiError && !(err.status === 429 || err.status >= 500)) throw err;
lastError =
err instanceof TiktokApiError
? err
: new TiktokApiError(`TikTok binary upload network error: ${err instanceof Error ? err.message : String(err)}`, 502);
} finally {
clearTimeout(timer);
}
if (attempt < MAX_CHUNK_ATTEMPTS - 1) await sleep(retryDelayMs(res, attempt));
}
return { status: res.status, body: text };
throw lastError ?? new TiktokApiError('TikTok binary upload failed.', 502);
}
export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mimeType = 'video/mp4') {
const total = buffer.length;
if (!total) {
throw new TiktokApiError('Refusing to upload an empty file to TikTok.', 400);
}
const { chunkSize, totalChunkCount } = planTiktokChunks(total);
let result = { status: 0, body: '' };
for (let i = 0; i < totalChunkCount; i++) {
const start = i * chunkSize;
const isLast = i === totalChunkCount - 1;
const end = isLast ? total - 1 : start + chunkSize - 1;
result = await putTiktokChunk(uploadUrl, buffer.subarray(start, end + 1), {
start,
end,
total,
mimeType,
isLast,
});
}
return result;
}
// ─── Publish status polling ────────────────────────────────────────────────
export interface TiktokPublishStatus {
status: string;
failReason?: string;
raw: unknown;
}
const TERMINAL_PUBLISH_STATUSES = new Set(['SEND_TO_USER_INBOX', 'PUBLISH_COMPLETE', 'FAILED']);
// Polls with bounded backoff (TikTok allows max 30 status calls/min). For
// draft uploads only SEND_TO_USER_INBOX means the draft actually reached the
// creator's inbox.
export async function pollTiktokPublishStatus(publishId: string, { maxWaitMs = 60_000 } = {}) {
const delays = [2_000, 4_000, 8_000, 15_000, 30_000];
const startedAt = Date.now();
let last: TiktokPublishStatus = { status: 'PROCESSING_UPLOAD', raw: null };
for (let i = 0; Date.now() - startedAt < maxWaitMs; i++) {
await sleep(delays[Math.min(i, delays.length - 1)]);
try {
const result = await tiktokApi('https://open.tiktokapis.com/v2/post/publish/status/fetch/', {
method: 'POST',
body: JSON.stringify({ publish_id: publishId }),
});
const data = result?.data as Record<string, unknown> | undefined;
last = {
status: String(data?.status || 'UNKNOWN'),
failReason: typeof data?.fail_reason === 'string' && data.fail_reason ? data.fail_reason : undefined,
raw: data ?? null,
};
if (TERMINAL_PUBLISH_STATUSES.has(last.status)) return last;
} catch {
// Transient status-fetch failures must not fail an already-uploaded
// post; keep the last known state and try again within the window.
}
}
return last;
}