From 48e9e2992c6eb98cad5276cc770890b5a35170d1 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Mon, 13 Jul 2026 18:51:52 +0200 Subject: [PATCH] TikTok V6 --- ...aster-decision-content-and-market-proof.md | 219 ++++++++++++++++++ public/images/static-vs-dynamic-qr-de.svg | 71 ++++++ src/app/(main)/api/tiktok/upload/route.ts | 51 +++- src/app/de/[slug]/page.tsx | 3 +- src/components/marketing/AnswerFirstBlock.tsx | 31 ++- src/components/marketing/ScrollReveal.tsx | 35 +++ .../marketing/UseCasePageTemplate.tsx | 84 +++++-- src/lib/growth-pages-de.ts | 135 +++++++++++ src/lib/tiktok.ts | 159 +++++++++++-- 9 files changed, 747 insertions(+), 41 deletions(-) create mode 100644 .hermes/plans/2026-07-13_151727-qr-master-decision-content-and-market-proof.md create mode 100644 public/images/static-vs-dynamic-qr-de.svg create mode 100644 src/components/marketing/ScrollReveal.tsx diff --git a/.hermes/plans/2026-07-13_151727-qr-master-decision-content-and-market-proof.md b/.hermes/plans/2026-07-13_151727-qr-master-decision-content-and-market-proof.md new file mode 100644 index 0000000..c621217 --- /dev/null +++ b/.hermes/plans/2026-07-13_151727-qr-master-decision-content-and-market-proof.md @@ -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 funnel’s 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 template’s 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`: 3–4 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 venue’s 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 7–14 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. diff --git a/public/images/static-vs-dynamic-qr-de.svg b/public/images/static-vs-dynamic-qr-de.svg new file mode 100644 index 0000000..2dfeeba --- /dev/null +++ b/public/images/static-vs-dynamic-qr-de.svg @@ -0,0 +1,71 @@ + + Statischer und dynamischer QR-Code im Vergleich + Illustration: Ein statischer QR-Code enthält einen festen Link. Bei einem dynamischen QR-Code kann das Ziel später geändert werden. + + + + + + + + + + + + ILLUSTRATION + Vor dem Druck entscheiden + + + + + + + + + FESTER LINK + Statischer QR-Code + Link fest eingebrannt + + + FLEXIBLES ZIEL + Dynamischer QR-Code + Ziel später ändern + + + + + + + + + + + + ZIEL-URL + nicht änderbar + + + + + + + + + + + DASHBOARD + + + + + + + Neuer Link? Neuer QR-Code nötig. + + Neuer Link? Ziel im Dashboard ändern. + + + + + vs. + diff --git a/src/app/(main)/api/tiktok/upload/route.ts b/src/app/(main)/api/tiktok/upload/route.ts index 6d2a103..af52eb7 100644 --- a/src/app/(main)/api/tiktok/upload/route.ts +++ b/src/app/(main)/api/tiktok/upload/route.ts @@ -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'; diff --git a/src/app/de/[slug]/page.tsx b/src/app/de/[slug]/page.tsx index 94edb85..567e18f 100644 --- a/src/app/de/[slug]/page.tsx +++ b/src/app/de/[slug]/page.tsx @@ -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}`, diff --git a/src/components/marketing/AnswerFirstBlock.tsx b/src/components/marketing/AnswerFirstBlock.tsx index ece5af7..34321c5 100644 --- a/src/components/marketing/AnswerFirstBlock.tsx +++ b/src/components/marketing/AnswerFirstBlock.tsx @@ -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 = ({ whatIsIt, whenToUse, comparison, howTo, + labels: customLabels, className, }) => { + const labels = customLabels ?? defaultLabels; const leftValueFor = (item: ComparisonItem) => item.text ?? 'No'; return (
-

Quick Summary

+

{labels.summaryTitle}

{whatIsIt}

@@ -45,7 +64,7 @@ export const AnswerFirstBlock: React.FC = ({

- When to use this? + {labels.whenToUseTitle}

    {whenToUse.map((item, idx) => ( @@ -61,7 +80,7 @@ export const AnswerFirstBlock: React.FC = ({

    - Comparison + {labels.comparisonTitle}

    {comparison.items.map((item, idx) => ( @@ -82,12 +101,12 @@ export const AnswerFirstBlock: React.FC = ({ {item.value ? ( <>
    @@ -102,7 +121,7 @@ export const AnswerFirstBlock: React.FC = ({

    - How it works + {labels.howItWorksTitle}

      {howTo.steps.map((step, idx) => ( diff --git a/src/components/marketing/ScrollReveal.tsx b/src/components/marketing/ScrollReveal.tsx new file mode 100644 index 0000000..02aa066 --- /dev/null +++ b/src/components/marketing/ScrollReveal.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/marketing/UseCasePageTemplate.tsx b/src/components/marketing/UseCasePageTemplate.tsx index 69d115b..05955c2 100644 --- a/src/components/marketing/UseCasePageTemplate.tsx +++ b/src/components/marketing/UseCasePageTemplate.tsx @@ -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[]; + enableScrollReveal?: boolean; labels?: UseCaseTemplateLabels; }; @@ -433,6 +455,7 @@ export function UseCasePageTemplate({ authoritySignals = [], directAnswer, schemaData = [], + enableScrollReveal = false, labels = defaultLabels, }: UseCasePageTemplateProps) { return ( @@ -566,19 +589,39 @@ export function UseCasePageTemplate({ {/* AnswerFirst block - summary and quick details */}
      - + {enableScrollReveal ? ( + + + + ) : ( + + )}
      {directAnswer ? ( @@ -617,9 +660,8 @@ export function UseCasePageTemplate({
      {workflowCards.map((card, idx) => { const Icon = getWorkflowIcon(idx); - return ( + const cardContent = (
      @@ -633,6 +675,14 @@ export function UseCasePageTemplate({

      ); + + return enableScrollReveal ? ( + + {cardContent} + + ) : ( +
      {cardContent}
      + ); })}
      @@ -718,7 +768,7 @@ export function UseCasePageTemplate({
      -
      +
      {labels.nextStepEyebrow}
      @@ -730,7 +780,7 @@ export function UseCasePageTemplate({

      -
      +
      = { + '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', diff --git a/src/lib/tiktok.ts b/src/lib/tiktok.ts index c4ede73..e2be5d5 100644 --- a/src/lib/tiktok.ts +++ b/src/lib/tiktok.ts @@ -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, { - method: 'PUT', - headers: { - 'Content-Type': mimeType, - 'Content-Length': String(buffer.length), - }, - body: new Uint8Array(buffer), - }); +// ─── Binary upload (Content-Range, chunking, timeout, retries) ───────────── - const text = await res.text(); - if (!res.ok && res.status !== 201) { - throw new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status); +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': opts.mimeType, + 'Content-Length': String(chunk.length), + 'Content-Range': `bytes ${opts.start}-${opts.end}/${opts.total}`, + }, + body: new Uint8Array(chunk), + signal: controller.signal, + }); + const text = await res.text(); + // 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 | 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; }