TikTok V6
This commit is contained in:
@@ -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.
|
||||
71
public/images/static-vs-dynamic-qr-de.svg
Normal file
71
public/images/static-vs-dynamic-qr-de.svg
Normal 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 |
@@ -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';
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
35
src/components/marketing/ScrollReveal.tsx
Normal file
35
src/components/marketing/ScrollReveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user