TikTok V6
This commit is contained in:
@@ -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,19 +589,39 @@ 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">
|
||||
<AnswerFirstBlock
|
||||
whatIsIt={answer}
|
||||
whenToUse={whenToUse}
|
||||
comparison={{
|
||||
leftTitle: labels.comparisonLeftTitle,
|
||||
rightTitle: labels.comparisonRightTitle,
|
||||
items: comparisonItems,
|
||||
}}
|
||||
howTo={{
|
||||
steps: howToSteps,
|
||||
}}
|
||||
className="mt-0"
|
||||
/>
|
||||
{enableScrollReveal ? (
|
||||
<ScrollReveal>
|
||||
<AnswerFirstBlock
|
||||
whatIsIt={answer}
|
||||
whenToUse={whenToUse}
|
||||
comparison={{
|
||||
leftTitle: labels.comparisonLeftTitle,
|
||||
rightTitle: labels.comparisonRightTitle,
|
||||
items: comparisonItems,
|
||||
}}
|
||||
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, {
|
||||
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<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