diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 00d3398..171d5bc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,39 +1,39 @@ -{ - "permissions": { - "allow": [ - "Bash(docker-compose:*)", - "Bash(docker container prune:*)", - "Bash(npx prisma migrate dev:*)", - "Bash(npx prisma:*)", - "Bash(npm run dev)", - "Bash(timeout:*)", - "Bash(taskkill:*)", - "Bash(npx kill-port:*)", - "Bash(docker compose:*)", - "Bash(curl -I https://fonts.googleapis.com)", - "Bash(wsl:*)", - "Read(//c/Users/a931627/.ssh/**)", - "Bash(ssh-keygen:*)", - "Bash(cat:*)", - "Bash(git remote add:*)", - "Bash(git push:*)", - "Bash(git remote set-url:*)", - "Bash(npm install:*)", - "Bash(npm run build:*)", - "Bash(ls:*)", - "Bash(curl:*)", - "Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")", - "Bash(pkill:*)", - "Skill(shadcn-ui)", - "Bash(find:*)", - "Bash(ls -la \"/c/Users/User/Documents/QR-master/src/app/\\(main\\)/\\(marketing\\)/\")", - "Bash(npx tsc:*)" - ], - "deny": [], - "ask": [] - }, - "enabledMcpjsonServers": [ - "firecrawl", - "apify" - ] -} +{ + "permissions": { + "allow": [ + "Bash(docker-compose:*)", + "Bash(docker container prune:*)", + "Bash(npx prisma migrate dev:*)", + "Bash(npx prisma:*)", + "Bash(npm run dev)", + "Bash(timeout:*)", + "Bash(taskkill:*)", + "Bash(npx kill-port:*)", + "Bash(docker compose:*)", + "Bash(curl -I https://fonts.googleapis.com)", + "Bash(wsl:*)", + "Read(//c/Users/a931627/.ssh/**)", + "Bash(ssh-keygen:*)", + "Bash(cat:*)", + "Bash(git remote add:*)", + "Bash(git push:*)", + "Bash(git remote set-url:*)", + "Bash(npm install:*)", + "Bash(npm run build:*)", + "Bash(ls:*)", + "Bash(curl:*)", + "Bash(echo \"\n\n## CSRF Debug aktiviert!\n\nBitte teste jetzt:\n1. Browser zu http://localhost:3050/create\n2. Dynamic QR Code erstellen versuchen\n3. Server-Logs zeigen jetzt [CSRF Debug] Output\n\nIch sehe dann:\n- Ob headerToken vorhanden ist\n- Ob cookieToken vorhanden ist \n- Ob sie übereinstimmen\n\n---\n\nStripe Portal 500 Error ist separates Problem:\nhttps://dashboard.stripe.com/test/settings/billing/portal\n→ Customer Portal Configuration muss erstellt werden\n\")", + "Bash(pkill:*)", + "Skill(shadcn-ui)", + "Bash(find:*)", + "Bash(ls -la \"/c/Users/User/Documents/QR-master/src/app/\\(main\\)/\\(marketing\\)/\")", + "Bash(npx tsc:*)" + ], + "deny": [], + "ask": [] + }, + "enabledMcpjsonServers": [ + "firecrawl", + "apify" + ] +} diff --git a/docs/automations/qrmaster-internal-revops-export.md b/docs/automations/qrmaster-internal-revops-export.md index eef54d2..d016539 100644 --- a/docs/automations/qrmaster-internal-revops-export.md +++ b/docs/automations/qrmaster-internal-revops-export.md @@ -1,131 +1,131 @@ -# QR Master Internal RevOps Export API - -This endpoint exposes read-only QR Master admin/product data for trusted automations such as Hermes cron jobs. - -## Endpoint - -```http -GET /api/internal/revops-export -Authorization: Bearer -``` - -The endpoint accepts `INTERNAL_API_SECRET` first, with `CRON_SECRET` as a fallback for compatibility. - -## Purpose - -QR Master only exports structured data. It does **not** perform AI analysis, web research, email drafting, or lead outreach. - -Hermes is responsible for: - -- calling this API on a schedule, -- researching public websites/domains with its tools, -- deciding which leads are worth manual outreach, -- drafting personalized emails, -- sending Timo an internal briefing, -- never auto-sending messages to QR Master users. - -## Query Parameters - -| Parameter | Example | Notes | -|---|---|---| -| `range` | `24h`, `7d`, `30d`, `all` | Defaults to `24h`. | -| `from` | `2026-06-30T00:00:00Z` | Optional explicit start. Overrides `range`. | -| `to` | `2026-07-01T00:00:00Z` | Optional explicit end. Defaults to now if `from` is set. | -| `limit` | `100` | User/lead limit. Defaults to 100, max 500. | -| `allUsers` | `true` | Exports all users up to `limit`; otherwise exports users active/relevant in range. | - -## Example Calls - -Daily Hermes lead research context: - -```bash -curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ - "https://www.qrmaster.net/api/internal/revops-export?range=24h&limit=100" -``` - -Weekly progress context: - -```bash -curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ - "https://www.qrmaster.net/api/internal/revops-export?range=7d&limit=200" -``` - -Local development: - -```bash -curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ - "http://localhost:3050/api/internal/revops-export?range=24h" -``` - -## Response Shape - -Top-level fields: - -```json -{ - "ok": true, - "generatedAt": "2026-07-01T18:00:00.000Z", - "endpoint": "/api/internal/revops-export", - "range": { - "preset": "24h", - "from": "2026-06-30T18:00:00.000Z", - "to": "2026-07-01T18:00:00.000Z" - }, - "overview": {}, - "breakdowns": {}, - "users": [], - "leads": [], - "newsletterSubscriptions": [] -} -``` - -Each exported user includes: - -- identity: `id`, `name`, `email`, `emailDomain`, `createdAt`, `updatedAt` -- plan/subscription flags -- attribution fields -- onboarding fields -- lifecycle scores and timestamps -- aggregate stats -- QR codes, content, public path, and scan summaries -- integrations -- recent lifecycle logs - -## Safety Rules for Hermes Jobs - -Hermes should treat this API as internal source data and follow these rules: - -1. Do not send outreach emails directly to leads. -2. Send only an internal briefing/draft pack to Timo. -3. Use public web research only when it improves personalization. -4. Do not tell leads they were researched. -5. Avoid creepy language; frame personalization around their QR Master activity and likely workflow. -6. Never expose API secrets or raw sensitive internals in the briefing. -7. Prefer business-use signals: QR content type, destination, scans, onboarding use case, domain, company website, landing path. - -## Suggested Hermes Cron Jobs - -### Daily QR Master Lead Research - -Schedule: daily, evening. - -Prompt should: - -1. call `/api/internal/revops-export?range=24h`, -2. identify promising leads, -3. research public company/domain signals, -4. draft personalized outreach emails from Timo, -5. send Timo an internal email briefing, -6. never send emails to leads. - -### Weekly QR Master Progress Review - -Schedule: Sunday evening. - -Prompt should: - -1. call `/api/internal/revops-export?range=7d`, -2. summarize users, QR creation, scans, activations, paid/upgrades, -3. identify bottlenecks, -4. suggest next-week goals, -5. email Timo the review. +# QR Master Internal RevOps Export API + +This endpoint exposes read-only QR Master admin/product data for trusted automations such as Hermes cron jobs. + +## Endpoint + +```http +GET /api/internal/revops-export +Authorization: Bearer +``` + +The endpoint accepts `INTERNAL_API_SECRET` first, with `CRON_SECRET` as a fallback for compatibility. + +## Purpose + +QR Master only exports structured data. It does **not** perform AI analysis, web research, email drafting, or lead outreach. + +Hermes is responsible for: + +- calling this API on a schedule, +- researching public websites/domains with its tools, +- deciding which leads are worth manual outreach, +- drafting personalized emails, +- sending Timo an internal briefing, +- never auto-sending messages to QR Master users. + +## Query Parameters + +| Parameter | Example | Notes | +|---|---|---| +| `range` | `24h`, `7d`, `30d`, `all` | Defaults to `24h`. | +| `from` | `2026-06-30T00:00:00Z` | Optional explicit start. Overrides `range`. | +| `to` | `2026-07-01T00:00:00Z` | Optional explicit end. Defaults to now if `from` is set. | +| `limit` | `100` | User/lead limit. Defaults to 100, max 500. | +| `allUsers` | `true` | Exports all users up to `limit`; otherwise exports users active/relevant in range. | + +## Example Calls + +Daily Hermes lead research context: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "https://www.qrmaster.net/api/internal/revops-export?range=24h&limit=100" +``` + +Weekly progress context: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "https://www.qrmaster.net/api/internal/revops-export?range=7d&limit=200" +``` + +Local development: + +```bash +curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ + "http://localhost:3050/api/internal/revops-export?range=24h" +``` + +## Response Shape + +Top-level fields: + +```json +{ + "ok": true, + "generatedAt": "2026-07-01T18:00:00.000Z", + "endpoint": "/api/internal/revops-export", + "range": { + "preset": "24h", + "from": "2026-06-30T18:00:00.000Z", + "to": "2026-07-01T18:00:00.000Z" + }, + "overview": {}, + "breakdowns": {}, + "users": [], + "leads": [], + "newsletterSubscriptions": [] +} +``` + +Each exported user includes: + +- identity: `id`, `name`, `email`, `emailDomain`, `createdAt`, `updatedAt` +- plan/subscription flags +- attribution fields +- onboarding fields +- lifecycle scores and timestamps +- aggregate stats +- QR codes, content, public path, and scan summaries +- integrations +- recent lifecycle logs + +## Safety Rules for Hermes Jobs + +Hermes should treat this API as internal source data and follow these rules: + +1. Do not send outreach emails directly to leads. +2. Send only an internal briefing/draft pack to Timo. +3. Use public web research only when it improves personalization. +4. Do not tell leads they were researched. +5. Avoid creepy language; frame personalization around their QR Master activity and likely workflow. +6. Never expose API secrets or raw sensitive internals in the briefing. +7. Prefer business-use signals: QR content type, destination, scans, onboarding use case, domain, company website, landing path. + +## Suggested Hermes Cron Jobs + +### Daily QR Master Lead Research + +Schedule: daily, evening. + +Prompt should: + +1. call `/api/internal/revops-export?range=24h`, +2. identify promising leads, +3. research public company/domain signals, +4. draft personalized outreach emails from Timo, +5. send Timo an internal email briefing, +6. never send emails to leads. + +### Weekly QR Master Progress Review + +Schedule: Sunday evening. + +Prompt should: + +1. call `/api/internal/revops-export?range=7d`, +2. summarize users, QR creation, scans, activations, paid/upgrades, +3. identify bottlenecks, +4. suggest next-week goals, +5. email Timo the review. diff --git a/env.example b/env.example index dc94f68..6c32b24 100644 --- a/env.example +++ b/env.example @@ -27,12 +27,12 @@ REDIS_URL=redis://redis:6379 # Used for hashing IP addresses in analytics IP_SALT=your-ip-salt-here-change-in-production -# Features -ENABLE_DEMO=false - -# SEO Configuration -# Set to 'true' in production to allow search engine indexing -NEXT_PUBLIC_INDEXABLE=true +# Features +ENABLE_DEMO=false + +# SEO Configuration +# Set to 'true' in production to allow search engine indexing +NEXT_PUBLIC_INDEXABLE=true # Stripe Payment Configuration (Optional - for subscription payments) # Get your keys from: https://dashboard.stripe.com/apikeys @@ -48,3 +48,10 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= # Analytics (Optional - PostHog) NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com + +# TikTok Content Posting API (Hermes Agent automated posting) +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +TIKTOK_REDIRECT_URI=https://qrmaster.net/api/tiktok/callback +# Optional: protects /api/tiktok/connect from being triggered by strangers +TIKTOK_ADMIN_KEY= diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ffe950a..0cb440e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,11 +11,11 @@ datasource db { url = env("DATABASE_URL") } -model User { - id String @id @default(cuid()) - email String @unique - name String? - password String? +model User { + id String @id @default(cuid()) + email String @unique + name String? + password String? image String? emailVerified DateTime? createdAt DateTime @default(now()) @@ -32,56 +32,56 @@ model User { resetPasswordToken String? @unique resetPasswordExpires DateTime? - // Retention email tracking - activationNudgeSentAt DateTime? - upgradeNudgeSentAt DateTime? - thirtyDayNudgeSentAt DateTime? - - // RevOps attribution - signupSource String? - signupSourceSelfReported String? - signupMedium String? - signupCampaign String? - signupContent String? - signupTerm String? - signupReferrer String? - signupLandingPath String? - signupFirstSeenAt DateTime? - emailDomain String? - - // Onboarding and qualification - primaryUseCase String? - primaryGoal String? - jobRole String? - companyName String? - companyWebsite String? - teamSizeBucket String? - onboardingStartedAt DateTime? - sourceConfirmedAt DateTime? - useCaseSelectedAt DateTime? - goalSelectedAt DateTime? - profileCompletedAt DateTime? - firstQrCreatedAt DateTime? - firstDynamicQrAt DateTime? - firstStaticQrAt DateTime? - firstScanAt DateTime? - activationAt DateTime? - onboardingCompletedAt DateTime? - - // RevOps scoring - fitScore Int @default(0) - intentScore Int @default(0) - leadScore Int @default(0) - lifecycleStage String @default("cold") - lastQualifiedAt DateTime? - lastScoredAt DateTime? - - qrCodes QRCode[] - integrations Integration[] - accounts Account[] - sessions Session[] - lifecycleLogs UserLifecycleLog[] -} + // Retention email tracking + activationNudgeSentAt DateTime? + upgradeNudgeSentAt DateTime? + thirtyDayNudgeSentAt DateTime? + + // RevOps attribution + signupSource String? + signupSourceSelfReported String? + signupMedium String? + signupCampaign String? + signupContent String? + signupTerm String? + signupReferrer String? + signupLandingPath String? + signupFirstSeenAt DateTime? + emailDomain String? + + // Onboarding and qualification + primaryUseCase String? + primaryGoal String? + jobRole String? + companyName String? + companyWebsite String? + teamSizeBucket String? + onboardingStartedAt DateTime? + sourceConfirmedAt DateTime? + useCaseSelectedAt DateTime? + goalSelectedAt DateTime? + profileCompletedAt DateTime? + firstQrCreatedAt DateTime? + firstDynamicQrAt DateTime? + firstStaticQrAt DateTime? + firstScanAt DateTime? + activationAt DateTime? + onboardingCompletedAt DateTime? + + // RevOps scoring + fitScore Int @default(0) + intentScore Int @default(0) + leadScore Int @default(0) + lifecycleStage String @default("cold") + lastQualifiedAt DateTime? + lastScoredAt DateTime? + + qrCodes QRCode[] + integrations Integration[] + accounts Account[] + sessions Session[] + lifecycleLogs UserLifecycleLog[] +} enum Plan { FREE @@ -189,7 +189,7 @@ model QRScan { @@index([qrId, ts]) } -model Integration { +model Integration { id String @id @default(cuid()) userId String provider String @@ -198,22 +198,35 @@ model Integration { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - -model UserLifecycleLog { - id String @id @default(cuid()) - userId String - fromStage String? - toStage String - fitScore Int @default(0) - intentScore Int @default(0) - leadScore Int @default(0) - reason String? - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model TiktokIntegration { + id String @id @default(cuid()) + accountKey String @unique + openId String + accessToken String + refreshToken String + scope String? + accessTokenExpiresAt DateTime + refreshTokenExpiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model UserLifecycleLog { + id String @id @default(cuid()) + userId String + fromStage String? + toStage String + fitScore Int @default(0) + intentScore Int @default(0) + leadScore Int @default(0) + reason String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} model NewsletterSubscription { id String @id @default(cuid()) diff --git a/src/app/(main)/(marketing)/terms/page.tsx b/src/app/(main)/(marketing)/terms/page.tsx index 7d06443..8056bbc 100644 --- a/src/app/(main)/(marketing)/terms/page.tsx +++ b/src/app/(main)/(marketing)/terms/page.tsx @@ -1,147 +1,147 @@ -import React from 'react'; -import Link from 'next/link'; -import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto'; - -export const metadata = { - title: 'Terms of Service | QR Master', - description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.', - openGraph: { - title: 'Terms of Service | QR Master', - description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.', - url: 'https://www.qrmaster.net/terms', - type: 'website', - images: ['/og-image.png'], - }, -}; - -export default function TermsPage() { - return ( -
-
-
- - ← Back to Home - -
- -

Terms of Service

-

Last updated: July 2026

- -
-
-

1. Acceptance of Terms

-

- Welcome to QR Master ("we," "our," or "us"). By accessing or using our website and services, you agree to be - bound by these Terms of Service. If you do not agree to these terms, please do not use our services. -

-
- -
-

2. Description of Service

-

- QR Master provides a platform for creating and managing static and dynamic QR codes, including scan - analytics, customization tools, and bulk creation features. We may add, change, or remove features at any - time. -

-
- -
-

3. Accounts

-
    -
  • You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account.
  • -
  • You must be at least 16 years old to use our services.
  • -
  • You must provide accurate account information and keep it up to date.
  • -
  • You may delete your account at any time from your account settings.
  • -
-
- -
-

4. Subscriptions & Payments

-

- QR Master offers FREE, PRO, and BUSINESS subscription plans. Paid subscriptions are billed through Stripe - and renew automatically until cancelled. You can manage or cancel your subscription at any time through - the customer billing portal in your account settings. -

-

- Fees are non-refundable except where required by applicable law. Downgrading or cancelling a plan may - affect the availability of dynamic QR codes and other paid features. -

-
- -
-

5. Acceptable Use

-

You agree not to use QR Master to:

-
    -
  • Create or distribute QR codes linking to illegal, fraudulent, or harmful content
  • -
  • Spam, phish, or mislead scanners about the destination of a QR code
  • -
  • Attempt to circumvent rate limits, security measures, or plan restrictions
  • -
  • Interfere with or disrupt the integrity or performance of our services
  • -
  • Reverse engineer or resell access to our services without authorization
  • -
-

- We reserve the right to disable QR codes or accounts that violate this policy. -

-
- -
-

6. Intellectual Property

-

- QR Master and its original content, features, and functionality are owned by us and are protected by - copyright, trademark, and other intellectual property laws. You retain all rights to the content and - destinations you associate with your own QR codes. -

-
- -
-

7. Termination

-

- We may suspend or terminate your access to QR Master at any time if you violate these terms or misuse our - services. You may stop using our services and delete your account at any time. -

-
- -
-

8. Disclaimers & Limitation of Liability

-

- Our services are provided "as is" and "as available" without warranties of any kind. We do not control - and are not responsible for the content that a QR code links to once you set its destination. To the - maximum extent permitted by law, QR Master shall not be liable for any indirect, incidental, or - consequential damages arising from your use of the services. -

-
- -
-

9. Changes to These Terms

-

- We may update these Terms of Service from time to time. We will update the "Last updated" date above when - changes are made. Continued use of our services after changes take effect constitutes acceptance of the - revised terms. -

-
- -
-

10. Contact Us

-

- If you have questions about these Terms of Service, please contact us: -

-
-

- Email:{' '} - -

-

Website: qrmaster.net

-
-
-
- -
-

- - Back to Home - -

-
-
-
- ); -} +import React from 'react'; +import Link from 'next/link'; +import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto'; + +export const metadata = { + title: 'Terms of Service | QR Master', + description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.', + openGraph: { + title: 'Terms of Service | QR Master', + description: 'Read the QR Master Terms of Service to understand the rules, rights, and responsibilities that apply when you use our QR code platform.', + url: 'https://www.qrmaster.net/terms', + type: 'website', + images: ['/og-image.png'], + }, +}; + +export default function TermsPage() { + return ( +
+
+
+ + ← Back to Home + +
+ +

Terms of Service

+

Last updated: July 2026

+ +
+
+

1. Acceptance of Terms

+

+ Welcome to QR Master ("we," "our," or "us"). By accessing or using our website and services, you agree to be + bound by these Terms of Service. If you do not agree to these terms, please do not use our services. +

+
+ +
+

2. Description of Service

+

+ QR Master provides a platform for creating and managing static and dynamic QR codes, including scan + analytics, customization tools, and bulk creation features. We may add, change, or remove features at any + time. +

+
+ +
+

3. Accounts

+
    +
  • You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account.
  • +
  • You must be at least 16 years old to use our services.
  • +
  • You must provide accurate account information and keep it up to date.
  • +
  • You may delete your account at any time from your account settings.
  • +
+
+ +
+

4. Subscriptions & Payments

+

+ QR Master offers FREE, PRO, and BUSINESS subscription plans. Paid subscriptions are billed through Stripe + and renew automatically until cancelled. You can manage or cancel your subscription at any time through + the customer billing portal in your account settings. +

+

+ Fees are non-refundable except where required by applicable law. Downgrading or cancelling a plan may + affect the availability of dynamic QR codes and other paid features. +

+
+ +
+

5. Acceptable Use

+

You agree not to use QR Master to:

+
    +
  • Create or distribute QR codes linking to illegal, fraudulent, or harmful content
  • +
  • Spam, phish, or mislead scanners about the destination of a QR code
  • +
  • Attempt to circumvent rate limits, security measures, or plan restrictions
  • +
  • Interfere with or disrupt the integrity or performance of our services
  • +
  • Reverse engineer or resell access to our services without authorization
  • +
+

+ We reserve the right to disable QR codes or accounts that violate this policy. +

+
+ +
+

6. Intellectual Property

+

+ QR Master and its original content, features, and functionality are owned by us and are protected by + copyright, trademark, and other intellectual property laws. You retain all rights to the content and + destinations you associate with your own QR codes. +

+
+ +
+

7. Termination

+

+ We may suspend or terminate your access to QR Master at any time if you violate these terms or misuse our + services. You may stop using our services and delete your account at any time. +

+
+ +
+

8. Disclaimers & Limitation of Liability

+

+ Our services are provided "as is" and "as available" without warranties of any kind. We do not control + and are not responsible for the content that a QR code links to once you set its destination. To the + maximum extent permitted by law, QR Master shall not be liable for any indirect, incidental, or + consequential damages arising from your use of the services. +

+
+ +
+

9. Changes to These Terms

+

+ We may update these Terms of Service from time to time. We will update the "Last updated" date above when + changes are made. Continued use of our services after changes take effect constitutes acceptance of the + revised terms. +

+
+ +
+

10. Contact Us

+

+ If you have questions about these Terms of Service, please contact us: +

+
+

+ Email:{' '} + +

+

Website: qrmaster.net

+
+
+
+ +
+

+ + Back to Home + +

+
+
+
+ ); +} diff --git a/src/app/(main)/api/internal/revops-export/route.ts b/src/app/(main)/api/internal/revops-export/route.ts index 3f66b88..1b553a5 100644 --- a/src/app/(main)/api/internal/revops-export/route.ts +++ b/src/app/(main)/api/internal/revops-export/route.ts @@ -1,463 +1,463 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { getEmailDomain } from '@/lib/revops'; -import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server'; - -export const dynamic = 'force-dynamic'; - -type RangePreset = '24h' | '7d' | '30d' | 'all'; - -const DEFAULT_LIMIT = 100; -const MAX_LIMIT = 500; - -function isAuthorized(request: NextRequest): boolean { - const authHeader = request.headers.get('authorization'); - const token = process.env.INTERNAL_API_SECRET || process.env.CRON_SECRET; - - if (!token) return false; - return authHeader === `Bearer ${token}`; -} - -function parsePositiveInt(value: string | null, fallback: number, max: number) { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed <= 0) return fallback; - return Math.min(Math.floor(parsed), max); -} - -function parseDate(value: string | null): Date | null { - if (!value) return null; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function resolveDateRange(request: NextRequest) { - const now = new Date(); - const explicitFrom = parseDate(request.nextUrl.searchParams.get('from')); - const explicitTo = parseDate(request.nextUrl.searchParams.get('to')); - const range = (request.nextUrl.searchParams.get('range') || '24h') as RangePreset; - - if (explicitFrom || explicitTo) { - return { - from: explicitFrom, - to: explicitTo ?? now, - preset: 'custom', - }; - } - - if (range === 'all') { - return { from: null, to: now, preset: range }; - } - - const hours = range === '7d' ? 24 * 7 : range === '30d' ? 24 * 30 : 24; - return { - from: new Date(now.getTime() - hours * 60 * 60 * 1000), - to: now, - preset: range, - }; -} - -function withinRange(date: Date | null | undefined, from: Date | null, to: Date) { - if (!date) return false; - return (!from || date >= from) && date <= to; -} - -function jsonDate(value: Date | null | undefined) { - return value ? value.toISOString() : null; -} - -function safeJson(value: unknown) { - return value ?? null; -} - -function summarizeScans(scans: Array<{ - ts: Date; - isUnique: boolean; - country: string | null; - device: string | null; - os: string | null; - referrer: string | null; - utmSource: string | null; - utmMedium: string | null; - utmCampaign: string | null; -}>) { - const countries = new Map(); - const devices = new Map(); - const os = new Map(); - const referrers = new Map(); - const utmSources = new Map(); - const utmCampaigns = new Map(); - const scansByDay = new Map(); - - for (const scan of scans) { - const day = scan.ts.toISOString().slice(0, 10); - scansByDay.set(day, (scansByDay.get(day) || 0) + 1); - - const increment = (map: Map, key?: string | null) => { - if (!key) return; - map.set(key, (map.get(key) || 0) + 1); - }; - - increment(countries, scan.country); - increment(devices, scan.device); - increment(os, scan.os); - increment(referrers, scan.referrer); - increment(utmSources, scan.utmSource); - increment(utmCampaigns, scan.utmCampaign); - } - - const top = (map: Map) => - Array.from(map.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10) - .map(([key, count]) => ({ key, count })); - - return { - total: scans.length, - unique: scans.filter((scan) => scan.isUnique).length, - firstScanAt: jsonDate(scans[0]?.ts), - lastScanAt: jsonDate(scans[scans.length - 1]?.ts), - byDay: Array.from(scansByDay.entries()).map(([date, count]) => ({ date, count })), - topCountries: top(countries), - topDevices: top(devices), - topOperatingSystems: top(os), - topReferrers: top(referrers), - topUtmSources: top(utmSources), - topUtmCampaigns: top(utmCampaigns), - }; -} - -export async function GET(request: NextRequest) { - try { - if (!isAuthorized(request)) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { from, to, preset } = resolveDateRange(request); - const limit = parsePositiveInt(request.nextUrl.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT); - const includeAllUsers = request.nextUrl.searchParams.get('allUsers') === 'true' || preset === 'all'; - - const users = await db.user.findMany({ - where: includeAllUsers || !from ? undefined : { - OR: [ - { createdAt: { gte: from, lte: to } }, - { updatedAt: { gte: from, lte: to } }, - { firstQrCreatedAt: { gte: from, lte: to } }, - { firstDynamicQrAt: { gte: from, lte: to } }, - { firstStaticQrAt: { gte: from, lte: to } }, - { firstScanAt: { gte: from, lte: to } }, - { activationAt: { gte: from, lte: to } }, - { lastQualifiedAt: { gte: from, lte: to } }, - { lastScoredAt: { gte: from, lte: to } }, - { qrCodes: { some: { createdAt: { gte: from, lte: to } } } }, - { qrCodes: { some: { scans: { some: { ts: { gte: from, lte: to } } } } } }, - ], - }, - orderBy: [ - { leadScore: 'desc' }, - { createdAt: 'desc' }, - ], - take: limit, - select: { - id: true, - email: true, - name: true, - image: true, - emailVerified: true, - createdAt: true, - updatedAt: true, - plan: true, - stripeCustomerId: true, - stripeSubscriptionId: true, - stripePriceId: true, - stripeCurrentPeriodEnd: true, - signupSource: true, - signupSourceSelfReported: true, - signupMedium: true, - signupCampaign: true, - signupContent: true, - signupTerm: true, - signupReferrer: true, - signupLandingPath: true, - signupFirstSeenAt: true, - emailDomain: true, - primaryUseCase: true, - primaryGoal: true, - jobRole: true, - companyName: true, - companyWebsite: true, - teamSizeBucket: true, - onboardingStartedAt: true, - sourceConfirmedAt: true, - useCaseSelectedAt: true, - goalSelectedAt: true, - profileCompletedAt: true, - firstQrCreatedAt: true, - firstDynamicQrAt: true, - firstStaticQrAt: true, - firstScanAt: true, - activationAt: true, - onboardingCompletedAt: true, - fitScore: true, - intentScore: true, - leadScore: true, - lifecycleStage: true, - lastQualifiedAt: true, - lastScoredAt: true, - qrCodes: { - orderBy: { createdAt: 'desc' }, - take: 50, - select: { - id: true, - title: true, - type: true, - contentType: true, - content: true, - tags: true, - status: true, - style: true, - slug: true, - createdAt: true, - updatedAt: true, - scans: { - orderBy: { ts: 'asc' }, - take: 500, - select: { - id: true, - ts: true, - device: true, - os: true, - country: true, - referrer: true, - utmSource: true, - utmMedium: true, - utmCampaign: true, - isUnique: true, - }, - }, - _count: { - select: { scans: true }, - }, - }, - }, - lifecycleLogs: { - orderBy: { createdAt: 'desc' }, - take: 10, - select: { - fromStage: true, - toStage: true, - fitScore: true, - intentScore: true, - leadScore: true, - reason: true, - createdAt: true, - }, - }, - integrations: { - select: { - provider: true, - status: true, - createdAt: true, - updatedAt: true, - }, - }, - }, - }); - - const leads = await db.lead.findMany({ - where: from ? { createdAt: { gte: from, lte: to } } : undefined, - orderBy: { createdAt: 'desc' }, - take: limit, - select: { - id: true, - email: true, - source: true, - reprintCost: true, - updatesPerYear: true, - annualSavings: true, - createdAt: true, - updatedAt: true, - }, - }); - - const newsletterSubscriptions = await db.newsletterSubscription.findMany({ - where: from ? { createdAt: { gte: from, lte: to } } : undefined, - orderBy: { createdAt: 'desc' }, - take: limit, - select: { - id: true, - email: true, - source: true, - status: true, - createdAt: true, - updatedAt: true, - }, - }); - - const exportedUsers = users.map((user) => { - const metrics = getMetricSnapshot(user.qrCodes); - const allScans = user.qrCodes.flatMap((qr) => qr.scans).sort((a, b) => a.ts.getTime() - b.ts.getTime()); - const rangeScans = allScans.filter((scan) => withinRange(scan.ts, from, to)); - - return { - id: user.id, - name: user.name, - email: user.email, - emailDomain: user.emailDomain || getEmailDomain(user.email), - image: user.image, - emailVerified: jsonDate(user.emailVerified), - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - plan: user.plan, - stripe: { - hasCustomer: Boolean(user.stripeCustomerId), - hasSubscription: Boolean(user.stripeSubscriptionId), - priceId: user.stripePriceId, - currentPeriodEnd: jsonDate(user.stripeCurrentPeriodEnd), - }, - attribution: { - signupSource: user.signupSource, - signupSourceSelfReported: user.signupSourceSelfReported, - signupMedium: user.signupMedium, - signupCampaign: user.signupCampaign, - signupContent: user.signupContent, - signupTerm: user.signupTerm, - signupReferrer: user.signupReferrer, - signupLandingPath: user.signupLandingPath, - signupFirstSeenAt: jsonDate(user.signupFirstSeenAt), - }, - onboarding: { - primaryUseCase: user.primaryUseCase, - primaryGoal: user.primaryGoal, - jobRole: user.jobRole, - companyName: user.companyName, - companyWebsite: user.companyWebsite, - teamSizeBucket: user.teamSizeBucket, - onboardingStartedAt: jsonDate(user.onboardingStartedAt), - sourceConfirmedAt: jsonDate(user.sourceConfirmedAt), - useCaseSelectedAt: jsonDate(user.useCaseSelectedAt), - goalSelectedAt: jsonDate(user.goalSelectedAt), - profileCompletedAt: jsonDate(user.profileCompletedAt), - onboardingCompletedAt: jsonDate(user.onboardingCompletedAt), - }, - lifecycle: { - fitScore: user.fitScore, - intentScore: user.intentScore, - leadScore: user.leadScore, - lifecycleStage: user.lifecycleStage, - firstQrCreatedAt: jsonDate(user.firstQrCreatedAt), - firstDynamicQrAt: jsonDate(user.firstDynamicQrAt), - firstStaticQrAt: jsonDate(user.firstStaticQrAt), - firstScanAt: jsonDate(user.firstScanAt), - activationAt: jsonDate(user.activationAt), - lastQualifiedAt: jsonDate(user.lastQualifiedAt), - lastScoredAt: jsonDate(user.lastScoredAt), - upgradeBadges: getUpgradeCandidateBadges(user, metrics), - logs: user.lifecycleLogs.map((log) => ({ - ...log, - createdAt: log.createdAt.toISOString(), - })), - }, - stats: { - qrCount: metrics.qrCount, - dynamicQrCount: metrics.dynamicQrCount, - staticQrCount: metrics.qrCount - metrics.dynamicQrCount, - contentTypeCount: metrics.contentTypeCount, - businessishTypeCount: metrics.businessishTypeCount, - scanCount: metrics.scanCount, - scansInRange: rangeScans.length, - uniqueScansInRange: rangeScans.filter((scan) => scan.isUnique).length, - }, - scans: summarizeScans(allScans), - scansInRange: summarizeScans(rangeScans), - qrCodes: user.qrCodes.map((qr) => { - const qrScansInRange = qr.scans.filter((scan) => withinRange(scan.ts, from, to)); - return { - id: qr.id, - title: qr.title, - type: qr.type, - contentType: qr.contentType, - content: safeJson(qr.content), - tags: qr.tags, - status: qr.status, - style: safeJson(qr.style), - slug: qr.slug, - publicPath: `/r/${qr.slug}`, - createdAt: qr.createdAt.toISOString(), - updatedAt: qr.updatedAt.toISOString(), - scanCount: qr._count.scans, - scansInRange: qrScansInRange.length, - firstScanAt: jsonDate(qr.scans[0]?.ts), - lastScanAt: jsonDate(qr.scans[qr.scans.length - 1]?.ts), - scanSummary: summarizeScans(qr.scans), - scanSummaryInRange: summarizeScans(qrScansInRange), - }; - }), - integrations: user.integrations.map((integration) => ({ - ...integration, - createdAt: integration.createdAt.toISOString(), - updatedAt: integration.updatedAt.toISOString(), - })), - }; - }); - - const allQrs = exportedUsers.flatMap((user) => user.qrCodes.map((qr) => ({ userId: user.id, ...qr }))); - const qrsInRange = allQrs.filter((qr) => withinRange(new Date(qr.createdAt), from, to)); - const scansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.scansInRange, 0); - const uniqueScansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.uniqueScansInRange, 0); - - const contentTypeCounts = new Map(); - for (const qr of allQrs) { - contentTypeCounts.set(qr.contentType, (contentTypeCounts.get(qr.contentType) || 0) + 1); - } - - return NextResponse.json({ - ok: true, - generatedAt: new Date().toISOString(), - endpoint: '/api/internal/revops-export', - range: { - preset, - from: jsonDate(from), - to: to.toISOString(), - }, - limits: { - requestedUserLimit: limit, - maxUserLimit: MAX_LIMIT, - qrLimitPerUser: 50, - scanLimitPerQr: 500, - }, - overview: { - exportedUsers: exportedUsers.length, - newUsersInRange: exportedUsers.filter((user) => withinRange(new Date(user.createdAt), from, to)).length, - activatedUsersInRange: exportedUsers.filter((user) => withinRange(user.lifecycle.activationAt ? new Date(user.lifecycle.activationAt) : null, from, to)).length, - paidUsers: exportedUsers.filter((user) => user.plan === 'PRO' || user.plan === 'BUSINESS').length, - hotUsers: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'hot').length, - upgradeCandidates: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'upgrade_candidate').length, - qrCodesTotal: allQrs.length, - qrCodesCreatedInRange: qrsInRange.length, - scansInRange, - uniqueScansInRange, - reprintCalculatorLeadsInRange: leads.length, - newsletterSubscriptionsInRange: newsletterSubscriptions.length, - }, - breakdowns: { - qrContentTypes: Array.from(contentTypeCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([contentType, count]) => ({ contentType, count })), - }, - users: exportedUsers, - leads: leads.map((lead) => ({ - ...lead, - createdAt: lead.createdAt.toISOString(), - updatedAt: lead.updatedAt.toISOString(), - })), - newsletterSubscriptions: newsletterSubscriptions.map((subscription) => ({ - ...subscription, - createdAt: subscription.createdAt.toISOString(), - updatedAt: subscription.updatedAt.toISOString(), - })), - }); - } catch (error) { - console.error('Error exporting internal RevOps data:', error); - return NextResponse.json({ error: 'Failed to export RevOps data' }, { status: 500 }); - } -} +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getEmailDomain } from '@/lib/revops'; +import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server'; + +export const dynamic = 'force-dynamic'; + +type RangePreset = '24h' | '7d' | '30d' | 'all'; + +const DEFAULT_LIMIT = 100; +const MAX_LIMIT = 500; + +function isAuthorized(request: NextRequest): boolean { + const authHeader = request.headers.get('authorization'); + const token = process.env.INTERNAL_API_SECRET || process.env.CRON_SECRET; + + if (!token) return false; + return authHeader === `Bearer ${token}`; +} + +function parsePositiveInt(value: string | null, fallback: number, max: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(Math.floor(parsed), max); +} + +function parseDate(value: string | null): Date | null { + if (!value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function resolveDateRange(request: NextRequest) { + const now = new Date(); + const explicitFrom = parseDate(request.nextUrl.searchParams.get('from')); + const explicitTo = parseDate(request.nextUrl.searchParams.get('to')); + const range = (request.nextUrl.searchParams.get('range') || '24h') as RangePreset; + + if (explicitFrom || explicitTo) { + return { + from: explicitFrom, + to: explicitTo ?? now, + preset: 'custom', + }; + } + + if (range === 'all') { + return { from: null, to: now, preset: range }; + } + + const hours = range === '7d' ? 24 * 7 : range === '30d' ? 24 * 30 : 24; + return { + from: new Date(now.getTime() - hours * 60 * 60 * 1000), + to: now, + preset: range, + }; +} + +function withinRange(date: Date | null | undefined, from: Date | null, to: Date) { + if (!date) return false; + return (!from || date >= from) && date <= to; +} + +function jsonDate(value: Date | null | undefined) { + return value ? value.toISOString() : null; +} + +function safeJson(value: unknown) { + return value ?? null; +} + +function summarizeScans(scans: Array<{ + ts: Date; + isUnique: boolean; + country: string | null; + device: string | null; + os: string | null; + referrer: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; +}>) { + const countries = new Map(); + const devices = new Map(); + const os = new Map(); + const referrers = new Map(); + const utmSources = new Map(); + const utmCampaigns = new Map(); + const scansByDay = new Map(); + + for (const scan of scans) { + const day = scan.ts.toISOString().slice(0, 10); + scansByDay.set(day, (scansByDay.get(day) || 0) + 1); + + const increment = (map: Map, key?: string | null) => { + if (!key) return; + map.set(key, (map.get(key) || 0) + 1); + }; + + increment(countries, scan.country); + increment(devices, scan.device); + increment(os, scan.os); + increment(referrers, scan.referrer); + increment(utmSources, scan.utmSource); + increment(utmCampaigns, scan.utmCampaign); + } + + const top = (map: Map) => + Array.from(map.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([key, count]) => ({ key, count })); + + return { + total: scans.length, + unique: scans.filter((scan) => scan.isUnique).length, + firstScanAt: jsonDate(scans[0]?.ts), + lastScanAt: jsonDate(scans[scans.length - 1]?.ts), + byDay: Array.from(scansByDay.entries()).map(([date, count]) => ({ date, count })), + topCountries: top(countries), + topDevices: top(devices), + topOperatingSystems: top(os), + topReferrers: top(referrers), + topUtmSources: top(utmSources), + topUtmCampaigns: top(utmCampaigns), + }; +} + +export async function GET(request: NextRequest) { + try { + if (!isAuthorized(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { from, to, preset } = resolveDateRange(request); + const limit = parsePositiveInt(request.nextUrl.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT); + const includeAllUsers = request.nextUrl.searchParams.get('allUsers') === 'true' || preset === 'all'; + + const users = await db.user.findMany({ + where: includeAllUsers || !from ? undefined : { + OR: [ + { createdAt: { gte: from, lte: to } }, + { updatedAt: { gte: from, lte: to } }, + { firstQrCreatedAt: { gte: from, lte: to } }, + { firstDynamicQrAt: { gte: from, lte: to } }, + { firstStaticQrAt: { gte: from, lte: to } }, + { firstScanAt: { gte: from, lte: to } }, + { activationAt: { gte: from, lte: to } }, + { lastQualifiedAt: { gte: from, lte: to } }, + { lastScoredAt: { gte: from, lte: to } }, + { qrCodes: { some: { createdAt: { gte: from, lte: to } } } }, + { qrCodes: { some: { scans: { some: { ts: { gte: from, lte: to } } } } } }, + ], + }, + orderBy: [ + { leadScore: 'desc' }, + { createdAt: 'desc' }, + ], + take: limit, + select: { + id: true, + email: true, + name: true, + image: true, + emailVerified: true, + createdAt: true, + updatedAt: true, + plan: true, + stripeCustomerId: true, + stripeSubscriptionId: true, + stripePriceId: true, + stripeCurrentPeriodEnd: true, + signupSource: true, + signupSourceSelfReported: true, + signupMedium: true, + signupCampaign: true, + signupContent: true, + signupTerm: true, + signupReferrer: true, + signupLandingPath: true, + signupFirstSeenAt: true, + emailDomain: true, + primaryUseCase: true, + primaryGoal: true, + jobRole: true, + companyName: true, + companyWebsite: true, + teamSizeBucket: true, + onboardingStartedAt: true, + sourceConfirmedAt: true, + useCaseSelectedAt: true, + goalSelectedAt: true, + profileCompletedAt: true, + firstQrCreatedAt: true, + firstDynamicQrAt: true, + firstStaticQrAt: true, + firstScanAt: true, + activationAt: true, + onboardingCompletedAt: true, + fitScore: true, + intentScore: true, + leadScore: true, + lifecycleStage: true, + lastQualifiedAt: true, + lastScoredAt: true, + qrCodes: { + orderBy: { createdAt: 'desc' }, + take: 50, + select: { + id: true, + title: true, + type: true, + contentType: true, + content: true, + tags: true, + status: true, + style: true, + slug: true, + createdAt: true, + updatedAt: true, + scans: { + orderBy: { ts: 'asc' }, + take: 500, + select: { + id: true, + ts: true, + device: true, + os: true, + country: true, + referrer: true, + utmSource: true, + utmMedium: true, + utmCampaign: true, + isUnique: true, + }, + }, + _count: { + select: { scans: true }, + }, + }, + }, + lifecycleLogs: { + orderBy: { createdAt: 'desc' }, + take: 10, + select: { + fromStage: true, + toStage: true, + fitScore: true, + intentScore: true, + leadScore: true, + reason: true, + createdAt: true, + }, + }, + integrations: { + select: { + provider: true, + status: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }); + + const leads = await db.lead.findMany({ + where: from ? { createdAt: { gte: from, lte: to } } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + email: true, + source: true, + reprintCost: true, + updatesPerYear: true, + annualSavings: true, + createdAt: true, + updatedAt: true, + }, + }); + + const newsletterSubscriptions = await db.newsletterSubscription.findMany({ + where: from ? { createdAt: { gte: from, lte: to } } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { + id: true, + email: true, + source: true, + status: true, + createdAt: true, + updatedAt: true, + }, + }); + + const exportedUsers = users.map((user) => { + const metrics = getMetricSnapshot(user.qrCodes); + const allScans = user.qrCodes.flatMap((qr) => qr.scans).sort((a, b) => a.ts.getTime() - b.ts.getTime()); + const rangeScans = allScans.filter((scan) => withinRange(scan.ts, from, to)); + + return { + id: user.id, + name: user.name, + email: user.email, + emailDomain: user.emailDomain || getEmailDomain(user.email), + image: user.image, + emailVerified: jsonDate(user.emailVerified), + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + plan: user.plan, + stripe: { + hasCustomer: Boolean(user.stripeCustomerId), + hasSubscription: Boolean(user.stripeSubscriptionId), + priceId: user.stripePriceId, + currentPeriodEnd: jsonDate(user.stripeCurrentPeriodEnd), + }, + attribution: { + signupSource: user.signupSource, + signupSourceSelfReported: user.signupSourceSelfReported, + signupMedium: user.signupMedium, + signupCampaign: user.signupCampaign, + signupContent: user.signupContent, + signupTerm: user.signupTerm, + signupReferrer: user.signupReferrer, + signupLandingPath: user.signupLandingPath, + signupFirstSeenAt: jsonDate(user.signupFirstSeenAt), + }, + onboarding: { + primaryUseCase: user.primaryUseCase, + primaryGoal: user.primaryGoal, + jobRole: user.jobRole, + companyName: user.companyName, + companyWebsite: user.companyWebsite, + teamSizeBucket: user.teamSizeBucket, + onboardingStartedAt: jsonDate(user.onboardingStartedAt), + sourceConfirmedAt: jsonDate(user.sourceConfirmedAt), + useCaseSelectedAt: jsonDate(user.useCaseSelectedAt), + goalSelectedAt: jsonDate(user.goalSelectedAt), + profileCompletedAt: jsonDate(user.profileCompletedAt), + onboardingCompletedAt: jsonDate(user.onboardingCompletedAt), + }, + lifecycle: { + fitScore: user.fitScore, + intentScore: user.intentScore, + leadScore: user.leadScore, + lifecycleStage: user.lifecycleStage, + firstQrCreatedAt: jsonDate(user.firstQrCreatedAt), + firstDynamicQrAt: jsonDate(user.firstDynamicQrAt), + firstStaticQrAt: jsonDate(user.firstStaticQrAt), + firstScanAt: jsonDate(user.firstScanAt), + activationAt: jsonDate(user.activationAt), + lastQualifiedAt: jsonDate(user.lastQualifiedAt), + lastScoredAt: jsonDate(user.lastScoredAt), + upgradeBadges: getUpgradeCandidateBadges(user, metrics), + logs: user.lifecycleLogs.map((log) => ({ + ...log, + createdAt: log.createdAt.toISOString(), + })), + }, + stats: { + qrCount: metrics.qrCount, + dynamicQrCount: metrics.dynamicQrCount, + staticQrCount: metrics.qrCount - metrics.dynamicQrCount, + contentTypeCount: metrics.contentTypeCount, + businessishTypeCount: metrics.businessishTypeCount, + scanCount: metrics.scanCount, + scansInRange: rangeScans.length, + uniqueScansInRange: rangeScans.filter((scan) => scan.isUnique).length, + }, + scans: summarizeScans(allScans), + scansInRange: summarizeScans(rangeScans), + qrCodes: user.qrCodes.map((qr) => { + const qrScansInRange = qr.scans.filter((scan) => withinRange(scan.ts, from, to)); + return { + id: qr.id, + title: qr.title, + type: qr.type, + contentType: qr.contentType, + content: safeJson(qr.content), + tags: qr.tags, + status: qr.status, + style: safeJson(qr.style), + slug: qr.slug, + publicPath: `/r/${qr.slug}`, + createdAt: qr.createdAt.toISOString(), + updatedAt: qr.updatedAt.toISOString(), + scanCount: qr._count.scans, + scansInRange: qrScansInRange.length, + firstScanAt: jsonDate(qr.scans[0]?.ts), + lastScanAt: jsonDate(qr.scans[qr.scans.length - 1]?.ts), + scanSummary: summarizeScans(qr.scans), + scanSummaryInRange: summarizeScans(qrScansInRange), + }; + }), + integrations: user.integrations.map((integration) => ({ + ...integration, + createdAt: integration.createdAt.toISOString(), + updatedAt: integration.updatedAt.toISOString(), + })), + }; + }); + + const allQrs = exportedUsers.flatMap((user) => user.qrCodes.map((qr) => ({ userId: user.id, ...qr }))); + const qrsInRange = allQrs.filter((qr) => withinRange(new Date(qr.createdAt), from, to)); + const scansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.scansInRange, 0); + const uniqueScansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.uniqueScansInRange, 0); + + const contentTypeCounts = new Map(); + for (const qr of allQrs) { + contentTypeCounts.set(qr.contentType, (contentTypeCounts.get(qr.contentType) || 0) + 1); + } + + return NextResponse.json({ + ok: true, + generatedAt: new Date().toISOString(), + endpoint: '/api/internal/revops-export', + range: { + preset, + from: jsonDate(from), + to: to.toISOString(), + }, + limits: { + requestedUserLimit: limit, + maxUserLimit: MAX_LIMIT, + qrLimitPerUser: 50, + scanLimitPerQr: 500, + }, + overview: { + exportedUsers: exportedUsers.length, + newUsersInRange: exportedUsers.filter((user) => withinRange(new Date(user.createdAt), from, to)).length, + activatedUsersInRange: exportedUsers.filter((user) => withinRange(user.lifecycle.activationAt ? new Date(user.lifecycle.activationAt) : null, from, to)).length, + paidUsers: exportedUsers.filter((user) => user.plan === 'PRO' || user.plan === 'BUSINESS').length, + hotUsers: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'hot').length, + upgradeCandidates: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'upgrade_candidate').length, + qrCodesTotal: allQrs.length, + qrCodesCreatedInRange: qrsInRange.length, + scansInRange, + uniqueScansInRange, + reprintCalculatorLeadsInRange: leads.length, + newsletterSubscriptionsInRange: newsletterSubscriptions.length, + }, + breakdowns: { + qrContentTypes: Array.from(contentTypeCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([contentType, count]) => ({ contentType, count })), + }, + users: exportedUsers, + leads: leads.map((lead) => ({ + ...lead, + createdAt: lead.createdAt.toISOString(), + updatedAt: lead.updatedAt.toISOString(), + })), + newsletterSubscriptions: newsletterSubscriptions.map((subscription) => ({ + ...subscription, + createdAt: subscription.createdAt.toISOString(), + updatedAt: subscription.updatedAt.toISOString(), + })), + }); + } catch (error) { + console.error('Error exporting internal RevOps data:', error); + return NextResponse.json({ error: 'Failed to export RevOps data' }, { status: 500 }); + } +} diff --git a/src/app/(main)/api/tiktok/callback/route.ts b/src/app/(main)/api/tiktok/callback/route.ts new file mode 100644 index 0000000..94288c0 --- /dev/null +++ b/src/app/(main)/api/tiktok/callback/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { TIKTOK_ACCOUNT_KEY, TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok'; + +const textResponse = (body: string, status: number) => { + const response = new NextResponse(body, { + status, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + response.cookies.delete(TIKTOK_OAUTH_STATE_COOKIE_NAME); + return response; +}; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + const errorDescription = searchParams.get('error_description'); + const savedState = request.cookies.get(TIKTOK_OAUTH_STATE_COOKIE_NAME)?.value; + + if (error) { + return textResponse(`TikTok authorization failed: ${errorDescription || error}`, 400); + } + if (!code) { + return textResponse('Missing authorization code.', 400); + } + if (!state || !savedState || state !== savedState) { + return textResponse('Invalid OAuth state. Start over at /api/tiktok/connect.', 403); + } + + const clientKey = process.env.TIKTOK_CLIENT_KEY; + const clientSecret = process.env.TIKTOK_CLIENT_SECRET; + if (!clientKey || !clientSecret) { + return textResponse('TikTok client credentials are not configured.', 500); + } + + const redirectUri = + process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`; + + try { + const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: clientKey, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }), + }); + + const tokens = await tokenResponse.json(); + if (!tokenResponse.ok || tokens.error) { + throw new Error(tokens.error_description || tokens.error || 'TikTok token exchange failed'); + } + + const now = Date.now(); + const accessTokenExpiresAt = new Date(now + Number(tokens.expires_in || 0) * 1000); + const refreshTokenExpiresAt = tokens.refresh_expires_in + ? new Date(now + Number(tokens.refresh_expires_in) * 1000) + : null; + + await db.tiktokIntegration.upsert({ + where: { accountKey: TIKTOK_ACCOUNT_KEY }, + create: { + accountKey: TIKTOK_ACCOUNT_KEY, + openId: tokens.open_id, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scope: tokens.scope || null, + accessTokenExpiresAt, + refreshTokenExpiresAt, + }, + update: { + openId: tokens.open_id, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scope: tokens.scope || null, + accessTokenExpiresAt, + refreshTokenExpiresAt, + }, + }); + + return textResponse('TikTok account connected. You can close this tab.', 200); + } catch (err) { + console.error('TikTok callback error:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return textResponse(`Failed to connect TikTok account: ${message}`, 502); + } +} diff --git a/src/app/(main)/api/tiktok/connect/route.ts b/src/app/(main)/api/tiktok/connect/route.ts new file mode 100644 index 0000000..e850f41 --- /dev/null +++ b/src/app/(main)/api/tiktok/connect/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { TIKTOK_OAUTH_STATE_COOKIE_NAME } from '@/lib/tiktok'; + +const isProduction = process.env.NODE_ENV === 'production'; + +export async function GET(request: NextRequest) { + const adminKey = process.env.TIKTOK_ADMIN_KEY; + if (adminKey) { + const provided = request.nextUrl.searchParams.get('key'); + if (provided !== adminKey) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + } + + const clientKey = process.env.TIKTOK_CLIENT_KEY; + if (!clientKey) { + return NextResponse.json({ error: 'TIKTOK_CLIENT_KEY not configured' }, { status: 500 }); + } + + const redirectUri = + process.env.TIKTOK_REDIRECT_URI || `${process.env.NEXT_PUBLIC_APP_URL}/api/tiktok/callback`; + + const oauthState = crypto.randomUUID(); + + const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/'); + authUrl.searchParams.set('client_key', clientKey); + authUrl.searchParams.set('scope', 'user.info.basic,video.publish'); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('state', oauthState); + + const response = NextResponse.redirect(authUrl); + response.cookies.set(TIKTOK_OAUTH_STATE_COOKIE_NAME, oauthState, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }); + return response; +} diff --git a/src/app/(main)/api/tiktok/token/route.ts b/src/app/(main)/api/tiktok/token/route.ts new file mode 100644 index 0000000..9e3724a --- /dev/null +++ b/src/app/(main)/api/tiktok/token/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getValidTiktokTokens } from '@/lib/tiktok'; + +export async function GET(request: NextRequest) { + const adminKey = process.env.TIKTOK_ADMIN_KEY; + if (!adminKey) { + // Unlike /connect, this endpoint hands out live credentials — never expose + // it without a configured key. + return NextResponse.json( + { error: 'TIKTOK_ADMIN_KEY must be configured to expose tokens' }, + { status: 500 } + ); + } + + const provided = + request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); + if (provided !== adminKey) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const tokens = await getValidTiktokTokens(); + if (!tokens) { + return NextResponse.json( + { error: 'No TikTok account connected. Visit /api/tiktok/connect first.' }, + { status: 404 } + ); + } + + return NextResponse.json({ + access_token: tokens.accessToken, + open_id: tokens.openId, + scope: tokens.scope, + expires_at: tokens.accessTokenExpiresAt.toISOString(), + }); + } catch (err) { + console.error('TikTok token endpoint error:', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/i18n/de.json b/src/i18n/de.json index d6a278e..6f03f3b 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1,442 +1,442 @@ -{ - "nav": { - "features": "Funktionen", - "pricing": "Preise", - "faq": "FAQ", - "blog": "Blog", - "login": "Anmelden", - "dashboard": "Dashboard", - "about": "Über uns", - "contact": "Kontakt", - "signup": "Registrieren", - "learn": "Lernen", - "create_qr": "QR erstellen", - "bulk_creation": "Massen-Erstellung", - "analytics": "Analytik", - "settings": "Einstellungen", - "cta": "Kostenlos starten", - "tools": "Kostenlose Tools", - "all_free": "Alle Generatoren sind 100% kostenlos", - "resources": "Ressourcen", - "all_industries": "Branchen" - }, - "hero": { - "badge": "Kostenloser QR-Code-Generator", - "title": "Erstellen Sie QR-Codes, die überall funktionieren", - "subtitle": "Generieren Sie statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Kostenlos für immer.", - "features": [ - "Keine Kreditkarte zum Starten erforderlich", - "QR-Codes für immer kostenlos erstellen", - "Erweiterte Verfolgung und Analytik", - "Individuelle Farben und Stile" - ], - "cta_primary": "QR-Code kostenlos erstellen", - "cta_secondary": "Preise ansehen", - "engagement_badge": "Kostenlos für immer", - "get_started": "Loslegen", - "view_full_pricing": "Alle Preisdetails ansehen →" - }, - "trust": { - "users": "Aktive Nutzer", - "codes": "QR-Codes erstellt", - "scans": "Scans verfolgt", - "countries": "Länder" - }, - "industries": { - "restaurant": "Restaurant-Kette", - "tech": "Tech-Startup", - "realestate": "Immobilien", - "events": "Event-Agentur", - "retail": "Einzelhandel", - "healthcare": "Gesundheitswesen" - }, - "templates": { - "title": "Mit einer Vorlage beginnen", - "restaurant": "Restaurant-Menü", - "business": "Visitenkarte", - "vcard": "Kontaktkarte", - "event": "Event-Ticket", - "use_template": "Vorlage verwenden →" - }, - "generator": { - "title": "Sofortiger QR-Code-Generator", - "url_placeholder": "Geben Sie hier Ihre URL ein...", - "foreground": "Vordergrund", - "background": "Hintergrund", - "corners": "Ecken", - "size": "Größe", - "contrast_good": "Guter Kontrast", - "download_svg": "SVG herunterladen", - "download_png": "PNG herunterladen", - "save_track": "Speichern & Verfolgen", - "live_preview": "Live-Vorschau", - "demo_note": "Dies ist ein Demo-QR-Code" - }, - "static_vs_dynamic": { - "title": "Warum dynamische QR-Codes Ihnen Geld sparen", - "description": "Hören Sie auf, Materialien neu zu drucken. Wechseln Sie Ziele sofort und verfolgen Sie jeden Scan.", - "static": { - "title": "Statische QR-Codes", - "subtitle": "Immer kostenlos", - "description": "Perfekt für permanente Inhalte, die sich nie ändern", - "features": [ - "Inhalt kann nicht bearbeitet werden", - "Keine Scan-Verfolgung", - "Funktioniert für immer", - "Kein Konto erforderlich" - ] - }, - "dynamic": { - "title": "Dynamische QR-Codes", - "subtitle": "Empfohlen", - "description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen", - "features": [ - "Inhalt jederzeit bearbeiten", - "Erweiterte Analytik", - "Individuelles Branding", - "Bulk-Operationen" - ] - } - }, - "features": { - "title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen", - "analytics": { - "title": "Erweiterte Analytik", - "description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken." - }, - "customization": { - "title": "Vollständige Anpassung", - "description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen." - }, - "unlimited": { - "title": "Unbegrenzte statische QR-Codes", - "description": "Erstellen Sie so viele statische QR-Codes wie Sie benötigen. Kostenlos für immer, ohne Limits." - }, - "bulk": { - "title": "Bulk-Operationen", - "description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung." - }, - "integrations": { - "title": "Integrationen", - "description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools." - }, - "api": { - "title": "Entwickler-API", - "description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API." - }, - "support": { - "title": "24/7 Support", - "description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team." - } - }, - "pricing": { - "title": "Wählen Sie Ihren Plan", - "subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", - "choose_plan": "Wählen Sie Ihren Plan", - "select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", - "current_plan": "Aktueller Plan", - "upgrade_to": "Upgrade auf", - "downgrade_to_free": "Zu Kostenlos zurückstufen", - "most_popular": "Beliebteste", - "all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.", - "free": { - "title": "Kostenlos", - "name": "Free", - "price": "€0", - "period": "für immer", - "features": [ - "3 aktive dynamische QR-Codes (8 Typen verfügbar)", - "Unbegrenzte statische QR-Codes", - "Basis-Scan-Tracking", - "Standard QR-Design-Vorlagen" - ] - }, - "pro": { - "title": "Pro", - "name": "Pro", - "price": "€9", - "period": "pro Monat", - "badge": "Beliebteste", - "features": [ - "50 dynamische QR-Codes", - "Unbegrenzte statische QR-Codes", - "Erweiterte Analytik (Scans, Geräte, Standorte)", - "Individuelles Branding (Farben)", - "Download als SVG/PNG" - ] - }, - "business": { - "title": "Business", - "name": "Business", - "price": "€29", - "period": "pro Monat", - "features": [ - "500 dynamische QR-Codes", - "Unbegrenzte statische QR-Codes", - "Alles aus Pro", - "Massen-QR-Erstellung (bis zu 1.000)", - "Prioritäts-E-Mail-Support", - "Erweiterte Tracking & Insights" - ] - }, - "enterprise": { - "title": "Enterprise", - "name": "Enterprise", - "price": "Individuell", - "period": "", - "features": [ - "∞ dynamische QR-Codes", - "Unbegrenzte statische QR-Codes", - "Alles aus Business", - "Eigener Account Manager" - ], - "contact": "Kontakt aufnehmen" - } - }, - "faq": { - "title": "Häufig gestellte Fragen", - "questions": { - "account": { - "question": "Benötige ich ein Konto, um QR-Codes zu erstellen?", - "answer": "Für statische QR-Codes ist kein Konto erforderlich. Dynamische QR-Codes mit Tracking- und Bearbeitungsfunktionen erfordern jedoch ein kostenloses Konto." - }, - "static_vs_dynamic": { - "question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?", - "answer": "Statische QR-Codes enthalten feste Inhalte, die nicht geändert werden können. Dynamische QR-Codes können jederzeit bearbeitet werden und bieten detaillierte Analytik." - }, - "forever": { - "question": "Funktionieren meine QR-Codes für immer?", - "answer": "Statische QR-Codes funktionieren für immer, da der Inhalt direkt eingebettet ist. Dynamische QR-Codes funktionieren, solange Ihr Konto aktiv ist." - }, - "file_type": { - "question": "Welchen Dateityp sollte ich zum Drucken verwenden?", - "answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität." - }, - "password": { - "question": "Kann ich einen QR-Code mit einem Passwort schützen?", - "answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes." - }, - "analytics": { - "question": "Wie funktioniert die Analytik?", - "answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert." - }, - "privacy": { - "question": "Verfolgen Sie persönliche Daten?", - "answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header." - }, - "bulk": { - "question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?", - "answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen." - }, - "no_account": { - "question": "Kann ich QR-Codes auch ohne Anmeldung erstellen?", - "answer": "Ja, Sie können unbegrenzt statische QR-Codes ohne Registrierung erstellen. Diese sind sofort einsatzbereit und funktionieren für immer. Für dynamische QR-Codes mit Tracking und Bearbeitung ist ein kostenloses Konto erforderlich." - }, - "restaurant_menu": { - "question": "Kann ich eine digitale Speisekarte per QR-Code erstellen?", - "answer": "Ja, mit dem PDF-QR-Code können Sie Ihre Speisekarte als digitale Version erstellen. Gäste scannen den Code und sehen die Speisekarte auf ihrem Handy. Änderungen an der Karte sind ohne Neudruck möglich." - }, - "scan_rate": { - "question": "Was beeinflusst die Scan-Rate meines QR-Codes?", - "answer": "Die Scan-Rate hängt von mehreren Faktoren ab: Platzierung (auf Augenhöhe), Größe (mindestens 2x2 cm), Kontrast (dunkel auf hell), Call-to-Action ('Scannen Sie hier') und Vorhandensein Ihres Logos für Vertrauen." - }, - "branding": { - "question": "Kann ich meinen QR-Code mit meinem Logo branden?", - "answer": "Ja, ab dem Pro-Plan können Sie Ihre QR-Codes mit Ihrem Firmenlogo, individuellen Farben und Stilen anpassen. Dies erhöht die Vertrauenswürdigkeit und Wiedererkennung." - }, - "data_security": { - "question": "Sind meine QR-Code-Daten in Deutschland sicher?", - "answer": "Wir hosten auf europäischen Servern und folgen der DSGVO. Scan-Daten werden anonymisiert gespeichert. Wir geben keine persönlichen Daten an Dritte weiter." - } - } - }, - "dashboard": { - "title": "Dashboard", - "subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance", - "stats": { - "total_scans": "Gesamte Scans", - "active_codes": "Aktive QR-Codes", - "conversion_rate": "Konversionsrate" - }, - "recent_codes": "Aktuelle QR-Codes", - "blog_resources": "Blog & Ressourcen", - "menu": { - "edit": "Bearbeiten", - "duplicate": "Duplizieren", - "pause": "Pausieren", - "delete": "Löschen" - } - }, - "create": { - "title": "QR-Code erstellen", - "subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding", - "content": "Inhalt", - "type": "QR-Code-Typ", - "style": "Stil & Branding", - "preview": "Live-Vorschau", - "title_label": "Titel", - "title_placeholder": "Mein QR-Code", - "content_type": "Inhaltstyp", - "url_label": "URL", - "url_placeholder": "https://beispiel.de", - "tags_label": "Tags (durch Komma getrennt)", - "tags_placeholder": "marketing, kampagne, 2025", - "qr_code_type": "QR-Code-Typ", - "dynamic": "Dynamisch", - "static": "Statisch", - "recommended": "Empfohlen", - "dynamic_description": "Dynamisch: Scans verfolgen, URL später bearbeiten, Analytik ansehen. QR enthält Tracking-Link.", - "static_description": "Statisch: Direkt zum Inhalt, kein Tracking, nicht bearbeitbar. QR enthält tatsächlichen Inhalt.", - "foreground_color": "Vordergrundfarbe", - "background_color": "Hintergrundfarbe", - "corner_style": "Eckenstil", - "size": "Größe", - "good_contrast": "Guter Kontrast", - "contrast_ratio": "Kontrastverhältnis", - "download_svg": "SVG herunterladen", - "download_png": "PNG herunterladen", - "save_qr_code": "QR-Code speichern" - }, - "analytics": { - "title": "Analytik", - "subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes", - "export_report": "Bericht exportieren", - "from_last_period": "vom letzten Zeitraum", - "no_mobile_scans": "Keine mobilen Scans", - "of_total": "der Gesamtmenge", - "ranges": { - "7d": "7 Tage", - "30d": "30 Tage", - "90d": "90 Tage" - }, - "kpis": { - "total_scans": "Gesamte Scans", - "avg_scans": "Ø Scans/QR", - "mobile_usage": "Mobile Nutzung", - "top_country": "Top Land" - }, - "charts": { - "scans_over_time": "Scans über Zeit", - "device_types": "Gerätetypen", - "top_countries": "Top Länder" - }, - "table": { - "qr_code": "QR-Code", - "type": "Typ", - "total_scans": "Gesamte Scans", - "unique_scans": "Einzigartige Scans", - "conversion": "Konversion", - "trend": "Trend", - "scans": "Scans", - "percentage": "Prozent", - "country": "Land", - "performance": "Performance", - "created": "Erstellt", - "status": "Status" - }, - "performance_title": "QR-Code-Performance" - }, - "bulk": { - "title": "Massen-Erstellung", - "subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien", - "template_warning_title": "Bitte folgen Sie dem Vorlagenformat", - "template_warning_text": "Laden Sie die Vorlage unten herunter und folgen Sie dem Format genau. Ihre CSV muss Spalten für Titel und Inhalt (URL) enthalten.", - "static_only_title": "Nur statische QR-Codes", - "static_only_text": "Massen-Erstellung generiert statische QR-Codes, die nach der Erstellung nicht bearbeitet werden können. Diese QR-Codes beinhalten kein Tracking oder Analytik. Perfekt für Druckmaterialien und Offline-Nutzung.", - "download_template": "Vorlage herunterladen", - "no_file_selected": "Keine ausgewählt", - "simple_format": "Einfaches Format", - "just_title_url": "Nur Titel & URL", - "static_qr_codes": "Statische QR-Codes", - "no_tracking": "Kein Tracking enthalten", - "instant_download": "Sofortiger Download", - "get_zip": "ZIP mit allen SVGs erhalten", - "max_rows": "max 1.000 Zeilen", - "steps": { - "upload": "Datei hochladen", - "preview": "Vorschau & Zuordnung", - "download": "Herunterladen" - }, - "drag_drop": "Datei hier hinziehen", - "or_click": "oder klicken zum Durchsuchen", - "supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)" - }, - "integrations": { - "title": "Integrationen", - "metrics": { - "total_codes": "QR-Codes Gesamt", - "active_integrations": "Aktive Integrationen", - "sync_status": "Sync-Status", - "available_services": "Verfügbare Services" - }, - "zapier": { - "title": "Zapier", - "description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps", - "features": [ - "Trigger bei neuen QR-Codes", - "Codes aus anderen Apps erstellen", - "Scan-Daten synchronisieren" - ] - }, - "airtable": { - "title": "Airtable", - "description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen", - "features": [ - "Bidirektionale Synchronisation", - "Individuelle Feldzuordnung", - "Echtzeit-Updates" - ] - }, - "sheets": { - "title": "Google Sheets", - "description": "Exportieren Sie Daten automatisch zu Google Sheets", - "features": [ - "Automatisierte Exporte", - "Individuelle Vorlagen", - "Geplante Updates" - ] - }, - "activate": "Aktivieren & Konfigurieren" - }, - "settings": { - "title": "Einstellungen", - "subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen", - "tabs": { - "profile": "Profil", - "billing": "Abrechnung", - "team": "Team & Rollen", - "api": "API-Schlüssel", - "workspace": "Arbeitsbereich" - } - }, - "common": { - "save": "Speichern", - "cancel": "Abbrechen", - "delete": "Löschen", - "edit": "Bearbeiten", - "create": "Erstellen", - "loading": "Lädt...", - "error": "Ein Fehler ist aufgetreten", - "success": "Erfolgreich!" - }, - "footer": { - "product": "Produkt", - "features": "Funktionen", - "pricing": "Preise", - "faq": "FAQ", - "blog": "Blog", - "resources": "Ressourcen", - "full_pricing": "Alle Preise", - "all_questions": "Alle Fragen", - "all_articles": "Alle Artikel", - "learn": "Lernen", - "get_started": "Loslegen", - "legal": "Rechtliches", - "industries": "Branchen", - "privacy_policy": "Datenschutzerklärung", - "terms_of_service": "Nutzungsbedingungen", - "tagline": "Erstellen Sie benutzerdefinierte QR-Codes in Sekunden mit erweitertem Tracking und Analysen.", - "newsletter": "Newsletter-Anmeldung", - "rights_reserved": "QR Master. Alle Rechte vorbehalten." - } -} +{ + "nav": { + "features": "Funktionen", + "pricing": "Preise", + "faq": "FAQ", + "blog": "Blog", + "login": "Anmelden", + "dashboard": "Dashboard", + "about": "Über uns", + "contact": "Kontakt", + "signup": "Registrieren", + "learn": "Lernen", + "create_qr": "QR erstellen", + "bulk_creation": "Massen-Erstellung", + "analytics": "Analytik", + "settings": "Einstellungen", + "cta": "Kostenlos starten", + "tools": "Kostenlose Tools", + "all_free": "Alle Generatoren sind 100% kostenlos", + "resources": "Ressourcen", + "all_industries": "Branchen" + }, + "hero": { + "badge": "Kostenloser QR-Code-Generator", + "title": "Erstellen Sie QR-Codes, die überall funktionieren", + "subtitle": "Generieren Sie statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Kostenlos für immer.", + "features": [ + "Keine Kreditkarte zum Starten erforderlich", + "QR-Codes für immer kostenlos erstellen", + "Erweiterte Verfolgung und Analytik", + "Individuelle Farben und Stile" + ], + "cta_primary": "QR-Code kostenlos erstellen", + "cta_secondary": "Preise ansehen", + "engagement_badge": "Kostenlos für immer", + "get_started": "Loslegen", + "view_full_pricing": "Alle Preisdetails ansehen →" + }, + "trust": { + "users": "Aktive Nutzer", + "codes": "QR-Codes erstellt", + "scans": "Scans verfolgt", + "countries": "Länder" + }, + "industries": { + "restaurant": "Restaurant-Kette", + "tech": "Tech-Startup", + "realestate": "Immobilien", + "events": "Event-Agentur", + "retail": "Einzelhandel", + "healthcare": "Gesundheitswesen" + }, + "templates": { + "title": "Mit einer Vorlage beginnen", + "restaurant": "Restaurant-Menü", + "business": "Visitenkarte", + "vcard": "Kontaktkarte", + "event": "Event-Ticket", + "use_template": "Vorlage verwenden →" + }, + "generator": { + "title": "Sofortiger QR-Code-Generator", + "url_placeholder": "Geben Sie hier Ihre URL ein...", + "foreground": "Vordergrund", + "background": "Hintergrund", + "corners": "Ecken", + "size": "Größe", + "contrast_good": "Guter Kontrast", + "download_svg": "SVG herunterladen", + "download_png": "PNG herunterladen", + "save_track": "Speichern & Verfolgen", + "live_preview": "Live-Vorschau", + "demo_note": "Dies ist ein Demo-QR-Code" + }, + "static_vs_dynamic": { + "title": "Warum dynamische QR-Codes Ihnen Geld sparen", + "description": "Hören Sie auf, Materialien neu zu drucken. Wechseln Sie Ziele sofort und verfolgen Sie jeden Scan.", + "static": { + "title": "Statische QR-Codes", + "subtitle": "Immer kostenlos", + "description": "Perfekt für permanente Inhalte, die sich nie ändern", + "features": [ + "Inhalt kann nicht bearbeitet werden", + "Keine Scan-Verfolgung", + "Funktioniert für immer", + "Kein Konto erforderlich" + ] + }, + "dynamic": { + "title": "Dynamische QR-Codes", + "subtitle": "Empfohlen", + "description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen", + "features": [ + "Inhalt jederzeit bearbeiten", + "Erweiterte Analytik", + "Individuelles Branding", + "Bulk-Operationen" + ] + } + }, + "features": { + "title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen", + "analytics": { + "title": "Erweiterte Analytik", + "description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken." + }, + "customization": { + "title": "Vollständige Anpassung", + "description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen." + }, + "unlimited": { + "title": "Unbegrenzte statische QR-Codes", + "description": "Erstellen Sie so viele statische QR-Codes wie Sie benötigen. Kostenlos für immer, ohne Limits." + }, + "bulk": { + "title": "Bulk-Operationen", + "description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung." + }, + "integrations": { + "title": "Integrationen", + "description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools." + }, + "api": { + "title": "Entwickler-API", + "description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API." + }, + "support": { + "title": "24/7 Support", + "description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team." + } + }, + "pricing": { + "title": "Wählen Sie Ihren Plan", + "subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", + "choose_plan": "Wählen Sie Ihren Plan", + "select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", + "current_plan": "Aktueller Plan", + "upgrade_to": "Upgrade auf", + "downgrade_to_free": "Zu Kostenlos zurückstufen", + "most_popular": "Beliebteste", + "all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.", + "free": { + "title": "Kostenlos", + "name": "Free", + "price": "€0", + "period": "für immer", + "features": [ + "3 aktive dynamische QR-Codes (8 Typen verfügbar)", + "Unbegrenzte statische QR-Codes", + "Basis-Scan-Tracking", + "Standard QR-Design-Vorlagen" + ] + }, + "pro": { + "title": "Pro", + "name": "Pro", + "price": "€9", + "period": "pro Monat", + "badge": "Beliebteste", + "features": [ + "50 dynamische QR-Codes", + "Unbegrenzte statische QR-Codes", + "Erweiterte Analytik (Scans, Geräte, Standorte)", + "Individuelles Branding (Farben)", + "Download als SVG/PNG" + ] + }, + "business": { + "title": "Business", + "name": "Business", + "price": "€29", + "period": "pro Monat", + "features": [ + "500 dynamische QR-Codes", + "Unbegrenzte statische QR-Codes", + "Alles aus Pro", + "Massen-QR-Erstellung (bis zu 1.000)", + "Prioritäts-E-Mail-Support", + "Erweiterte Tracking & Insights" + ] + }, + "enterprise": { + "title": "Enterprise", + "name": "Enterprise", + "price": "Individuell", + "period": "", + "features": [ + "∞ dynamische QR-Codes", + "Unbegrenzte statische QR-Codes", + "Alles aus Business", + "Eigener Account Manager" + ], + "contact": "Kontakt aufnehmen" + } + }, + "faq": { + "title": "Häufig gestellte Fragen", + "questions": { + "account": { + "question": "Benötige ich ein Konto, um QR-Codes zu erstellen?", + "answer": "Für statische QR-Codes ist kein Konto erforderlich. Dynamische QR-Codes mit Tracking- und Bearbeitungsfunktionen erfordern jedoch ein kostenloses Konto." + }, + "static_vs_dynamic": { + "question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?", + "answer": "Statische QR-Codes enthalten feste Inhalte, die nicht geändert werden können. Dynamische QR-Codes können jederzeit bearbeitet werden und bieten detaillierte Analytik." + }, + "forever": { + "question": "Funktionieren meine QR-Codes für immer?", + "answer": "Statische QR-Codes funktionieren für immer, da der Inhalt direkt eingebettet ist. Dynamische QR-Codes funktionieren, solange Ihr Konto aktiv ist." + }, + "file_type": { + "question": "Welchen Dateityp sollte ich zum Drucken verwenden?", + "answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität." + }, + "password": { + "question": "Kann ich einen QR-Code mit einem Passwort schützen?", + "answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes." + }, + "analytics": { + "question": "Wie funktioniert die Analytik?", + "answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert." + }, + "privacy": { + "question": "Verfolgen Sie persönliche Daten?", + "answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header." + }, + "bulk": { + "question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?", + "answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen." + }, + "no_account": { + "question": "Kann ich QR-Codes auch ohne Anmeldung erstellen?", + "answer": "Ja, Sie können unbegrenzt statische QR-Codes ohne Registrierung erstellen. Diese sind sofort einsatzbereit und funktionieren für immer. Für dynamische QR-Codes mit Tracking und Bearbeitung ist ein kostenloses Konto erforderlich." + }, + "restaurant_menu": { + "question": "Kann ich eine digitale Speisekarte per QR-Code erstellen?", + "answer": "Ja, mit dem PDF-QR-Code können Sie Ihre Speisekarte als digitale Version erstellen. Gäste scannen den Code und sehen die Speisekarte auf ihrem Handy. Änderungen an der Karte sind ohne Neudruck möglich." + }, + "scan_rate": { + "question": "Was beeinflusst die Scan-Rate meines QR-Codes?", + "answer": "Die Scan-Rate hängt von mehreren Faktoren ab: Platzierung (auf Augenhöhe), Größe (mindestens 2x2 cm), Kontrast (dunkel auf hell), Call-to-Action ('Scannen Sie hier') und Vorhandensein Ihres Logos für Vertrauen." + }, + "branding": { + "question": "Kann ich meinen QR-Code mit meinem Logo branden?", + "answer": "Ja, ab dem Pro-Plan können Sie Ihre QR-Codes mit Ihrem Firmenlogo, individuellen Farben und Stilen anpassen. Dies erhöht die Vertrauenswürdigkeit und Wiedererkennung." + }, + "data_security": { + "question": "Sind meine QR-Code-Daten in Deutschland sicher?", + "answer": "Wir hosten auf europäischen Servern und folgen der DSGVO. Scan-Daten werden anonymisiert gespeichert. Wir geben keine persönlichen Daten an Dritte weiter." + } + } + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance", + "stats": { + "total_scans": "Gesamte Scans", + "active_codes": "Aktive QR-Codes", + "conversion_rate": "Konversionsrate" + }, + "recent_codes": "Aktuelle QR-Codes", + "blog_resources": "Blog & Ressourcen", + "menu": { + "edit": "Bearbeiten", + "duplicate": "Duplizieren", + "pause": "Pausieren", + "delete": "Löschen" + } + }, + "create": { + "title": "QR-Code erstellen", + "subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding", + "content": "Inhalt", + "type": "QR-Code-Typ", + "style": "Stil & Branding", + "preview": "Live-Vorschau", + "title_label": "Titel", + "title_placeholder": "Mein QR-Code", + "content_type": "Inhaltstyp", + "url_label": "URL", + "url_placeholder": "https://beispiel.de", + "tags_label": "Tags (durch Komma getrennt)", + "tags_placeholder": "marketing, kampagne, 2025", + "qr_code_type": "QR-Code-Typ", + "dynamic": "Dynamisch", + "static": "Statisch", + "recommended": "Empfohlen", + "dynamic_description": "Dynamisch: Scans verfolgen, URL später bearbeiten, Analytik ansehen. QR enthält Tracking-Link.", + "static_description": "Statisch: Direkt zum Inhalt, kein Tracking, nicht bearbeitbar. QR enthält tatsächlichen Inhalt.", + "foreground_color": "Vordergrundfarbe", + "background_color": "Hintergrundfarbe", + "corner_style": "Eckenstil", + "size": "Größe", + "good_contrast": "Guter Kontrast", + "contrast_ratio": "Kontrastverhältnis", + "download_svg": "SVG herunterladen", + "download_png": "PNG herunterladen", + "save_qr_code": "QR-Code speichern" + }, + "analytics": { + "title": "Analytik", + "subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes", + "export_report": "Bericht exportieren", + "from_last_period": "vom letzten Zeitraum", + "no_mobile_scans": "Keine mobilen Scans", + "of_total": "der Gesamtmenge", + "ranges": { + "7d": "7 Tage", + "30d": "30 Tage", + "90d": "90 Tage" + }, + "kpis": { + "total_scans": "Gesamte Scans", + "avg_scans": "Ø Scans/QR", + "mobile_usage": "Mobile Nutzung", + "top_country": "Top Land" + }, + "charts": { + "scans_over_time": "Scans über Zeit", + "device_types": "Gerätetypen", + "top_countries": "Top Länder" + }, + "table": { + "qr_code": "QR-Code", + "type": "Typ", + "total_scans": "Gesamte Scans", + "unique_scans": "Einzigartige Scans", + "conversion": "Konversion", + "trend": "Trend", + "scans": "Scans", + "percentage": "Prozent", + "country": "Land", + "performance": "Performance", + "created": "Erstellt", + "status": "Status" + }, + "performance_title": "QR-Code-Performance" + }, + "bulk": { + "title": "Massen-Erstellung", + "subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien", + "template_warning_title": "Bitte folgen Sie dem Vorlagenformat", + "template_warning_text": "Laden Sie die Vorlage unten herunter und folgen Sie dem Format genau. Ihre CSV muss Spalten für Titel und Inhalt (URL) enthalten.", + "static_only_title": "Nur statische QR-Codes", + "static_only_text": "Massen-Erstellung generiert statische QR-Codes, die nach der Erstellung nicht bearbeitet werden können. Diese QR-Codes beinhalten kein Tracking oder Analytik. Perfekt für Druckmaterialien und Offline-Nutzung.", + "download_template": "Vorlage herunterladen", + "no_file_selected": "Keine ausgewählt", + "simple_format": "Einfaches Format", + "just_title_url": "Nur Titel & URL", + "static_qr_codes": "Statische QR-Codes", + "no_tracking": "Kein Tracking enthalten", + "instant_download": "Sofortiger Download", + "get_zip": "ZIP mit allen SVGs erhalten", + "max_rows": "max 1.000 Zeilen", + "steps": { + "upload": "Datei hochladen", + "preview": "Vorschau & Zuordnung", + "download": "Herunterladen" + }, + "drag_drop": "Datei hier hinziehen", + "or_click": "oder klicken zum Durchsuchen", + "supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)" + }, + "integrations": { + "title": "Integrationen", + "metrics": { + "total_codes": "QR-Codes Gesamt", + "active_integrations": "Aktive Integrationen", + "sync_status": "Sync-Status", + "available_services": "Verfügbare Services" + }, + "zapier": { + "title": "Zapier", + "description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps", + "features": [ + "Trigger bei neuen QR-Codes", + "Codes aus anderen Apps erstellen", + "Scan-Daten synchronisieren" + ] + }, + "airtable": { + "title": "Airtable", + "description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen", + "features": [ + "Bidirektionale Synchronisation", + "Individuelle Feldzuordnung", + "Echtzeit-Updates" + ] + }, + "sheets": { + "title": "Google Sheets", + "description": "Exportieren Sie Daten automatisch zu Google Sheets", + "features": [ + "Automatisierte Exporte", + "Individuelle Vorlagen", + "Geplante Updates" + ] + }, + "activate": "Aktivieren & Konfigurieren" + }, + "settings": { + "title": "Einstellungen", + "subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen", + "tabs": { + "profile": "Profil", + "billing": "Abrechnung", + "team": "Team & Rollen", + "api": "API-Schlüssel", + "workspace": "Arbeitsbereich" + } + }, + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "create": "Erstellen", + "loading": "Lädt...", + "error": "Ein Fehler ist aufgetreten", + "success": "Erfolgreich!" + }, + "footer": { + "product": "Produkt", + "features": "Funktionen", + "pricing": "Preise", + "faq": "FAQ", + "blog": "Blog", + "resources": "Ressourcen", + "full_pricing": "Alle Preise", + "all_questions": "Alle Fragen", + "all_articles": "Alle Artikel", + "learn": "Lernen", + "get_started": "Loslegen", + "legal": "Rechtliches", + "industries": "Branchen", + "privacy_policy": "Datenschutzerklärung", + "terms_of_service": "Nutzungsbedingungen", + "tagline": "Erstellen Sie benutzerdefinierte QR-Codes in Sekunden mit erweitertem Tracking und Analysen.", + "newsletter": "Newsletter-Anmeldung", + "rights_reserved": "QR Master. Alle Rechte vorbehalten." + } +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 548b526..48ea38b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,412 +1,412 @@ -{ - "nav": { - "features": "Features", - "pricing": "Pricing", - "faq": "FAQ", - "blog": "Blog", - "login": "Login", - "dashboard": "Dashboard", - "about": "About", - "contact": "Contact", - "signup": "Sign Up", - "learn": "Learn", - "create_qr": "Create QR", - "bulk_creation": "Bulk Creation", - "analytics": "Analytics", - "settings": "Settings", - "cta": "Get Started Free", - "tools": "Free Tools", - "all_free": "All generators are 100% free", - "resources": "Resources", - "all_industries": "Industries" - }, - "hero": { - "badge": "QR Master Free QR Code Generator", - "title": "QR Master: Free Dynamic QR Code Generator with Tracking", - "subtitle": "Create static and dynamic QR codes with editable destinations, scan tracking, custom branding, and bulk generation. Also searched as QRMaster and QR code master.", - "features": [ - "No credit card required to start", - "Create QR codes free forever", - "Advanced tracking and analytics", - "Custom colors and styles" - ], - "cta_primary": "Make a QR Code Free", - "cta_secondary": "View Pricing", - "engagement_badge": "Free Forever" - }, - "trust": { - "users": "Happy Users", - "codes": "Active QR Codes", - "scans": "Total Scans", - "countries": "Countries" - }, - "industries": { - "restaurant": "Restaurant Chain", - "tech": "Tech Startup", - "realestate": "Real Estate", - "events": "Event Agency", - "retail": "Retail Store", - "healthcare": "Healthcare" - }, - "templates": { - "title": "Start with a Template", - "restaurant": "Restaurant Menu", - "business": "Business Card", - "vcard": "Contact Card", - "event": "Event Ticket", - "use_template": "Use template →" - }, - "generator": { - "title": "Instant QR Code Generator", - "url_placeholder": "Enter your URL here...", - "foreground": "Foreground", - "background": "Background", - "corners": "Corners", - "size": "Size", - "contrast_good": "Good contrast", - "download_svg": "Download SVG", - "download_png": "Download PNG", - "save_track": "Save & Track", - "live_preview": "Live Preview", - "demo_note": "This is a demo QR code" - }, - "static_vs_dynamic": { - "title": "Why Dynamic QR Codes Save You Money", - "description": "Stop re-printing materials. Switch destinations instantly and track every scan.", - "static": { - "title": "Static QR Codes", - "subtitle": "Always Free", - "description": "Perfect for permanent content that never changes", - "features": [ - "Content cannot be edited", - "No scan tracking", - "Works forever", - "No account required" - ] - }, - "dynamic": { - "title": "Dynamic QR Codes", - "subtitle": "Recommended", - "description": "Full control with tracking and editing capabilities", - "features": [ - "Edit content anytime", - "Advanced analytics", - "Custom branding", - "Bulk operations" - ] - } - }, - "features": { - "title": "Everything you need to create professional QR codes", - "analytics": { - "title": "Advanced Analytics", - "description": "Track scans, locations, devices, and user behavior with detailed insights." - }, - "customization": { - "title": "Full Customization", - "description": "Brand your QR codes with custom colors and styling options." - }, - "unlimited": { - "title": "Unlimited Static QR Codes", - "description": "Create as many static QR codes as you need. Free forever, no limits." - }, - "bulk": { - "title": "Bulk Operations", - "description": "Create hundreds of QR codes at once with CSV import and batch processing." - }, - "integrations": { - "title": "Integrations", - "description": "Connect with Zapier, Airtable, Google Sheets, and more popular tools." - }, - "api": { - "title": "Developer API", - "description": "Integrate QR code generation into your applications with our REST API." - }, - "support": { - "title": "24/7 Support", - "description": "Get help when you need it with our dedicated customer support team." - } - }, - "pricing": { - "title": "Choose Your Plan", - "subtitle": "Select the perfect plan for your QR code needs", - "choose_plan": "Choose Your Plan", - "select_plan": "Select the perfect plan for your QR code needs", - "current_plan": "Current Plan", - "upgrade_to": "Upgrade to", - "downgrade_to_free": "Downgrade to Free", - "most_popular": "Most Popular", - "all_plans_note": "All plans include unlimited static QR codes and basic customization.", - "free": { - "title": "Free", - "name": "Free", - "price": "€0", - "period": "forever", - "features": [ - "3 active dynamic QR codes (8 types available)", - "Unlimited static QR codes", - "Basic scan tracking", - "Standard QR design templates", - "Download as SVG/PNG" - ] - }, - "pro": { - "title": "Pro", - "name": "Pro", - "price": "€9", - "period": "per month", - "badge": "Most Popular", - "features": [ - "50 dynamic QR codes", - "Unlimited static QR codes", - "Advanced analytics (scans, devices, locations)", - "Custom branding (colors & logos)" - ] - }, - "business": { - "title": "Business", - "name": "Business", - "price": "€29", - "period": "per month", - "features": [ - "500 dynamic QR codes", - "Unlimited static QR codes", - "Everything from Pro", - "Bulk QR Creation (up to 1,000)", - "Priority email support", - "Advanced tracking & insights" - ] - }, - "enterprise": { - "title": "Enterprise", - "name": "Enterprise", - "price": "Custom", - "period": "", - "features": [ - "∞ dynamic QR codes", - "Unlimited static QR codes", - "Everything from Business", - "Dedicated Account Manager" - ], - "contact": "Contact Us" - } - }, - "faq": { - "title": "Frequently Asked Questions", - "questions": { - "account": { - "question": "Do I need an account to create QR codes?", - "answer": "No account is required for static QR codes. However, dynamic QR codes with tracking and editing capabilities require a free account." - }, - "static_vs_dynamic": { - "question": "What's the difference between static and dynamic QR codes?", - "answer": "Static QR codes contain fixed content that cannot be changed. Dynamic QR codes can be edited anytime and provide detailed analytics." - }, - "forever": { - "question": "Will my QR codes work forever?", - "answer": "Static QR codes work forever as the content is embedded directly. Dynamic QR codes work as long as your account is active." - }, - "file_type": { - "question": "What file type should I use for printing?", - "answer": "For print materials, we recommend SVG format for scalability or high-resolution PNG (300+ DPI) for best quality." - }, - "password": { - "question": "Can I password-protect a QR code?", - "answer": "Yes, Pro and Business plans include password protection and access control features for your QR codes." - }, - "analytics": { - "question": "How do analytics work?", - "answer": "We track scans, locations, devices, and referrers while respecting user privacy. No personal data is stored." - }, - "privacy": { - "question": "Do you track personal data?", - "answer": "We respect privacy and only collect anonymous usage data. IP addresses are hashed and we honor Do Not Track headers." - }, - "bulk": { - "question": "Can I bulk-create codes with my own data?", - "answer": "Yes, you can upload CSV or Excel files to create multiple QR codes at once with custom data mapping." - } - } - }, - "dashboard": { - "title": "Dashboard", - "subtitle": "Manage your QR codes and track performance", - "stats": { - "total_scans": "Total Scans", - "active_codes": "Active QR Codes", - "conversion_rate": "Conversion Rate" - }, - "recent_codes": "Recent QR Codes", - "blog_resources": "Blog & Resources", - "menu": { - "edit": "Edit", - "duplicate": "Duplicate", - "pause": "Pause", - "delete": "Delete" - } - }, - "create": { - "title": "Create QR Code", - "subtitle": "Generate dynamic and static QR codes with custom branding", - "content": "Content", - "type": "QR Code Type", - "style": "Style & Branding", - "preview": "Live Preview", - "title_label": "Title", - "title_placeholder": "My QR Code", - "content_type": "Content Type", - "url_label": "URL", - "url_placeholder": "https://example.com", - "tags_label": "Tags (comma-separated)", - "tags_placeholder": "marketing, campaign, 2025", - "qr_code_type": "QR Code Type", - "dynamic": "Dynamic", - "static": "Static", - "recommended": "Recommended", - "dynamic_description": "Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.", - "static_description": "Static: Direct to content, no tracking, cannot edit. QR contains actual content.", - "foreground_color": "Foreground Color", - "background_color": "Background Color", - "corner_style": "Corner Style", - "size": "Size", - "good_contrast": "Good contrast", - "contrast_ratio": "Contrast ratio", - "download_svg": "Download SVG", - "download_png": "Download PNG", - "save_qr_code": "Save QR Code" - }, - "analytics": { - "title": "Analytics", - "subtitle": "Track and analyze your QR code performance", - "export_report": "Export Report", - "from_last_period": "from last period", - "no_mobile_scans": "No mobile scans", - "of_total": "of total", - "ranges": { - "7d": "7 Days", - "30d": "30 Days", - "90d": "90 Days" - }, - "kpis": { - "total_scans": "Total Scans", - "avg_scans": "Avg Scans/QR", - "mobile_usage": "Mobile Usage", - "top_country": "Top Country" - }, - "charts": { - "scans_over_time": "Scans Over Time", - "device_types": "Device Types", - "top_countries": "Top Countries" - }, - "table": { - "qr_code": "QR Code", - "type": "Type", - "total_scans": "Total Scans", - "unique_scans": "Unique Scans", - "conversion": "Conversion", - "trend": "Trend", - "scans": "Scans", - "percentage": "Percentage", - "country": "Country", - "performance": "Performance", - "created": "Created", - "status": "Status" - }, - "performance_title": "QR Code Performance" - }, - "bulk": { - "title": "Bulk Creation", - "subtitle": "Create multiple QR codes at once from CSV or Excel files", - "template_warning_title": "Please Follow the Template Format", - "template_warning_text": "Download the template below and follow the format exactly. Your CSV must include columns for title and content (URL).", - "static_only_title": "Static QR Codes Only", - "static_only_text": "Bulk creation generates static QR codes that cannot be edited after creation. These QR codes do not include tracking or analytics. Perfect for print materials and offline use.", - "download_template": "Download Template", - "no_file_selected": "No file selected", - "simple_format": "Simple Format", - "just_title_url": "Just title & URL", - "static_qr_codes": "Static QR Codes", - "no_tracking": "No tracking included", - "instant_download": "Instant Download", - "get_zip": "Get ZIP with all SVGs", - "max_rows": "max 1,000 rows", - "steps": { - "upload": "Upload File", - "preview": "Preview & Map", - "download": "Download" - }, - "drag_drop": "Drag & drop your file here", - "or_click": "or click to browse", - "supported_formats": "Supports CSV, XLS, XLSX (max 1,000 rows)" - }, - "integrations": { - "title": "Integrations", - "metrics": { - "total_codes": "QR Codes Total", - "active_integrations": "Active Integrations", - "sync_status": "Sync Status", - "available_services": "Available Services" - }, - "zapier": { - "title": "Zapier", - "description": "Automate QR code creation with 5000+ apps", - "features": [ - "Trigger on new QR codes", - "Create codes from other apps", - "Sync scan data" - ] - }, - "airtable": { - "title": "Airtable", - "description": "Sync QR codes with your Airtable bases", - "features": ["Two-way sync", "Custom field mapping", "Real-time updates"] - }, - "sheets": { - "title": "Google Sheets", - "description": "Export data to Google Sheets automatically", - "features": ["Automated exports", "Custom templates", "Scheduled updates"] - }, - "activate": "Activate & Configure" - }, - "settings": { - "title": "Settings", - "subtitle": "Manage your account settings and preferences", - "tabs": { - "profile": "Profile", - "billing": "Billing", - "team": "Team & Roles", - "api": "API Keys", - "workspace": "Workspace" - } - }, - "common": { - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "edit": "Edit", - "create": "Create", - "loading": "Loading...", - "error": "An error occurred", - "success": "Success!" - }, - "footer": { - "product": "Product", - "features": "Features", - "pricing": "Pricing", - "faq": "FAQ", - "blog": "Blog", - "resources": "Resources", - "full_pricing": "Full Pricing", - "all_questions": "All Questions", - "all_articles": "All Articles", - "learn": "Learn", - "get_started": "Get Started", - "legal": "Legal", - "industries": "Industries", - "privacy_policy": "Privacy Policy", - "terms_of_service": "Terms of Service", - "tagline": "Create custom QR codes in seconds with advanced tracking and analytics.", - "newsletter": "Newsletter signup", - "rights_reserved": "QR Master. All rights reserved." - } -} +{ + "nav": { + "features": "Features", + "pricing": "Pricing", + "faq": "FAQ", + "blog": "Blog", + "login": "Login", + "dashboard": "Dashboard", + "about": "About", + "contact": "Contact", + "signup": "Sign Up", + "learn": "Learn", + "create_qr": "Create QR", + "bulk_creation": "Bulk Creation", + "analytics": "Analytics", + "settings": "Settings", + "cta": "Get Started Free", + "tools": "Free Tools", + "all_free": "All generators are 100% free", + "resources": "Resources", + "all_industries": "Industries" + }, + "hero": { + "badge": "QR Master Free QR Code Generator", + "title": "QR Master: Free Dynamic QR Code Generator with Tracking", + "subtitle": "Create static and dynamic QR codes with editable destinations, scan tracking, custom branding, and bulk generation. Also searched as QRMaster and QR code master.", + "features": [ + "No credit card required to start", + "Create QR codes free forever", + "Advanced tracking and analytics", + "Custom colors and styles" + ], + "cta_primary": "Make a QR Code Free", + "cta_secondary": "View Pricing", + "engagement_badge": "Free Forever" + }, + "trust": { + "users": "Happy Users", + "codes": "Active QR Codes", + "scans": "Total Scans", + "countries": "Countries" + }, + "industries": { + "restaurant": "Restaurant Chain", + "tech": "Tech Startup", + "realestate": "Real Estate", + "events": "Event Agency", + "retail": "Retail Store", + "healthcare": "Healthcare" + }, + "templates": { + "title": "Start with a Template", + "restaurant": "Restaurant Menu", + "business": "Business Card", + "vcard": "Contact Card", + "event": "Event Ticket", + "use_template": "Use template →" + }, + "generator": { + "title": "Instant QR Code Generator", + "url_placeholder": "Enter your URL here...", + "foreground": "Foreground", + "background": "Background", + "corners": "Corners", + "size": "Size", + "contrast_good": "Good contrast", + "download_svg": "Download SVG", + "download_png": "Download PNG", + "save_track": "Save & Track", + "live_preview": "Live Preview", + "demo_note": "This is a demo QR code" + }, + "static_vs_dynamic": { + "title": "Why Dynamic QR Codes Save You Money", + "description": "Stop re-printing materials. Switch destinations instantly and track every scan.", + "static": { + "title": "Static QR Codes", + "subtitle": "Always Free", + "description": "Perfect for permanent content that never changes", + "features": [ + "Content cannot be edited", + "No scan tracking", + "Works forever", + "No account required" + ] + }, + "dynamic": { + "title": "Dynamic QR Codes", + "subtitle": "Recommended", + "description": "Full control with tracking and editing capabilities", + "features": [ + "Edit content anytime", + "Advanced analytics", + "Custom branding", + "Bulk operations" + ] + } + }, + "features": { + "title": "Everything you need to create professional QR codes", + "analytics": { + "title": "Advanced Analytics", + "description": "Track scans, locations, devices, and user behavior with detailed insights." + }, + "customization": { + "title": "Full Customization", + "description": "Brand your QR codes with custom colors and styling options." + }, + "unlimited": { + "title": "Unlimited Static QR Codes", + "description": "Create as many static QR codes as you need. Free forever, no limits." + }, + "bulk": { + "title": "Bulk Operations", + "description": "Create hundreds of QR codes at once with CSV import and batch processing." + }, + "integrations": { + "title": "Integrations", + "description": "Connect with Zapier, Airtable, Google Sheets, and more popular tools." + }, + "api": { + "title": "Developer API", + "description": "Integrate QR code generation into your applications with our REST API." + }, + "support": { + "title": "24/7 Support", + "description": "Get help when you need it with our dedicated customer support team." + } + }, + "pricing": { + "title": "Choose Your Plan", + "subtitle": "Select the perfect plan for your QR code needs", + "choose_plan": "Choose Your Plan", + "select_plan": "Select the perfect plan for your QR code needs", + "current_plan": "Current Plan", + "upgrade_to": "Upgrade to", + "downgrade_to_free": "Downgrade to Free", + "most_popular": "Most Popular", + "all_plans_note": "All plans include unlimited static QR codes and basic customization.", + "free": { + "title": "Free", + "name": "Free", + "price": "€0", + "period": "forever", + "features": [ + "3 active dynamic QR codes (8 types available)", + "Unlimited static QR codes", + "Basic scan tracking", + "Standard QR design templates", + "Download as SVG/PNG" + ] + }, + "pro": { + "title": "Pro", + "name": "Pro", + "price": "€9", + "period": "per month", + "badge": "Most Popular", + "features": [ + "50 dynamic QR codes", + "Unlimited static QR codes", + "Advanced analytics (scans, devices, locations)", + "Custom branding (colors & logos)" + ] + }, + "business": { + "title": "Business", + "name": "Business", + "price": "€29", + "period": "per month", + "features": [ + "500 dynamic QR codes", + "Unlimited static QR codes", + "Everything from Pro", + "Bulk QR Creation (up to 1,000)", + "Priority email support", + "Advanced tracking & insights" + ] + }, + "enterprise": { + "title": "Enterprise", + "name": "Enterprise", + "price": "Custom", + "period": "", + "features": [ + "∞ dynamic QR codes", + "Unlimited static QR codes", + "Everything from Business", + "Dedicated Account Manager" + ], + "contact": "Contact Us" + } + }, + "faq": { + "title": "Frequently Asked Questions", + "questions": { + "account": { + "question": "Do I need an account to create QR codes?", + "answer": "No account is required for static QR codes. However, dynamic QR codes with tracking and editing capabilities require a free account." + }, + "static_vs_dynamic": { + "question": "What's the difference between static and dynamic QR codes?", + "answer": "Static QR codes contain fixed content that cannot be changed. Dynamic QR codes can be edited anytime and provide detailed analytics." + }, + "forever": { + "question": "Will my QR codes work forever?", + "answer": "Static QR codes work forever as the content is embedded directly. Dynamic QR codes work as long as your account is active." + }, + "file_type": { + "question": "What file type should I use for printing?", + "answer": "For print materials, we recommend SVG format for scalability or high-resolution PNG (300+ DPI) for best quality." + }, + "password": { + "question": "Can I password-protect a QR code?", + "answer": "Yes, Pro and Business plans include password protection and access control features for your QR codes." + }, + "analytics": { + "question": "How do analytics work?", + "answer": "We track scans, locations, devices, and referrers while respecting user privacy. No personal data is stored." + }, + "privacy": { + "question": "Do you track personal data?", + "answer": "We respect privacy and only collect anonymous usage data. IP addresses are hashed and we honor Do Not Track headers." + }, + "bulk": { + "question": "Can I bulk-create codes with my own data?", + "answer": "Yes, you can upload CSV or Excel files to create multiple QR codes at once with custom data mapping." + } + } + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "Manage your QR codes and track performance", + "stats": { + "total_scans": "Total Scans", + "active_codes": "Active QR Codes", + "conversion_rate": "Conversion Rate" + }, + "recent_codes": "Recent QR Codes", + "blog_resources": "Blog & Resources", + "menu": { + "edit": "Edit", + "duplicate": "Duplicate", + "pause": "Pause", + "delete": "Delete" + } + }, + "create": { + "title": "Create QR Code", + "subtitle": "Generate dynamic and static QR codes with custom branding", + "content": "Content", + "type": "QR Code Type", + "style": "Style & Branding", + "preview": "Live Preview", + "title_label": "Title", + "title_placeholder": "My QR Code", + "content_type": "Content Type", + "url_label": "URL", + "url_placeholder": "https://example.com", + "tags_label": "Tags (comma-separated)", + "tags_placeholder": "marketing, campaign, 2025", + "qr_code_type": "QR Code Type", + "dynamic": "Dynamic", + "static": "Static", + "recommended": "Recommended", + "dynamic_description": "Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.", + "static_description": "Static: Direct to content, no tracking, cannot edit. QR contains actual content.", + "foreground_color": "Foreground Color", + "background_color": "Background Color", + "corner_style": "Corner Style", + "size": "Size", + "good_contrast": "Good contrast", + "contrast_ratio": "Contrast ratio", + "download_svg": "Download SVG", + "download_png": "Download PNG", + "save_qr_code": "Save QR Code" + }, + "analytics": { + "title": "Analytics", + "subtitle": "Track and analyze your QR code performance", + "export_report": "Export Report", + "from_last_period": "from last period", + "no_mobile_scans": "No mobile scans", + "of_total": "of total", + "ranges": { + "7d": "7 Days", + "30d": "30 Days", + "90d": "90 Days" + }, + "kpis": { + "total_scans": "Total Scans", + "avg_scans": "Avg Scans/QR", + "mobile_usage": "Mobile Usage", + "top_country": "Top Country" + }, + "charts": { + "scans_over_time": "Scans Over Time", + "device_types": "Device Types", + "top_countries": "Top Countries" + }, + "table": { + "qr_code": "QR Code", + "type": "Type", + "total_scans": "Total Scans", + "unique_scans": "Unique Scans", + "conversion": "Conversion", + "trend": "Trend", + "scans": "Scans", + "percentage": "Percentage", + "country": "Country", + "performance": "Performance", + "created": "Created", + "status": "Status" + }, + "performance_title": "QR Code Performance" + }, + "bulk": { + "title": "Bulk Creation", + "subtitle": "Create multiple QR codes at once from CSV or Excel files", + "template_warning_title": "Please Follow the Template Format", + "template_warning_text": "Download the template below and follow the format exactly. Your CSV must include columns for title and content (URL).", + "static_only_title": "Static QR Codes Only", + "static_only_text": "Bulk creation generates static QR codes that cannot be edited after creation. These QR codes do not include tracking or analytics. Perfect for print materials and offline use.", + "download_template": "Download Template", + "no_file_selected": "No file selected", + "simple_format": "Simple Format", + "just_title_url": "Just title & URL", + "static_qr_codes": "Static QR Codes", + "no_tracking": "No tracking included", + "instant_download": "Instant Download", + "get_zip": "Get ZIP with all SVGs", + "max_rows": "max 1,000 rows", + "steps": { + "upload": "Upload File", + "preview": "Preview & Map", + "download": "Download" + }, + "drag_drop": "Drag & drop your file here", + "or_click": "or click to browse", + "supported_formats": "Supports CSV, XLS, XLSX (max 1,000 rows)" + }, + "integrations": { + "title": "Integrations", + "metrics": { + "total_codes": "QR Codes Total", + "active_integrations": "Active Integrations", + "sync_status": "Sync Status", + "available_services": "Available Services" + }, + "zapier": { + "title": "Zapier", + "description": "Automate QR code creation with 5000+ apps", + "features": [ + "Trigger on new QR codes", + "Create codes from other apps", + "Sync scan data" + ] + }, + "airtable": { + "title": "Airtable", + "description": "Sync QR codes with your Airtable bases", + "features": ["Two-way sync", "Custom field mapping", "Real-time updates"] + }, + "sheets": { + "title": "Google Sheets", + "description": "Export data to Google Sheets automatically", + "features": ["Automated exports", "Custom templates", "Scheduled updates"] + }, + "activate": "Activate & Configure" + }, + "settings": { + "title": "Settings", + "subtitle": "Manage your account settings and preferences", + "tabs": { + "profile": "Profile", + "billing": "Billing", + "team": "Team & Roles", + "api": "API Keys", + "workspace": "Workspace" + } + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "loading": "Loading...", + "error": "An error occurred", + "success": "Success!" + }, + "footer": { + "product": "Product", + "features": "Features", + "pricing": "Pricing", + "faq": "FAQ", + "blog": "Blog", + "resources": "Resources", + "full_pricing": "Full Pricing", + "all_questions": "All Questions", + "all_articles": "All Articles", + "learn": "Learn", + "get_started": "Get Started", + "legal": "Legal", + "industries": "Industries", + "privacy_policy": "Privacy Policy", + "terms_of_service": "Terms of Service", + "tagline": "Create custom QR codes in seconds with advanced tracking and analytics.", + "newsletter": "Newsletter signup", + "rights_reserved": "QR Master. All rights reserved." + } +} diff --git a/src/lib/tiktok.ts b/src/lib/tiktok.ts new file mode 100644 index 0000000..ed9afbc --- /dev/null +++ b/src/lib/tiktok.ts @@ -0,0 +1,59 @@ +import { db } from '@/lib/db'; + +export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state'; +export const TIKTOK_ACCOUNT_KEY = 'hermes-agent'; + +// Refresh when the access token expires within this window, so Hermes never +// receives a token that dies mid-upload. +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + +export async function getValidTiktokTokens() { + const integration = await db.tiktokIntegration.findUnique({ + where: { accountKey: TIKTOK_ACCOUNT_KEY }, + }); + + if (!integration) { + return null; + } + + if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) { + return integration; + } + + const clientKey = process.env.TIKTOK_CLIENT_KEY; + const clientSecret = process.env.TIKTOK_CLIENT_SECRET; + if (!clientKey || !clientSecret) { + throw new Error('TikTok client credentials are not configured.'); + } + + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: clientKey, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: integration.refreshToken, + }), + }); + + const tokens = await response.json(); + if (!response.ok || tokens.error) { + throw new Error(tokens.error_description || tokens.error || 'TikTok token refresh failed'); + } + + const now = Date.now(); + return db.tiktokIntegration.update({ + where: { accountKey: TIKTOK_ACCOUNT_KEY }, + data: { + openId: tokens.open_id, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + scope: tokens.scope || null, + accessTokenExpiresAt: new Date(now + Number(tokens.expires_in || 0) * 1000), + refreshTokenExpiresAt: tokens.refresh_expires_in + ? new Date(now + Number(tokens.refresh_expires_in) * 1000) + : null, + }, + }); +}