TikTok api

This commit is contained in:
Timo Knuth
2026-07-02 13:06:50 +02:00
parent b0b70640ab
commit 0b9c8d2a8f
12 changed files with 1965 additions and 1712 deletions

View File

@@ -1,39 +1,39 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(docker-compose:*)", "Bash(docker-compose:*)",
"Bash(docker container prune:*)", "Bash(docker container prune:*)",
"Bash(npx prisma migrate dev:*)", "Bash(npx prisma migrate dev:*)",
"Bash(npx prisma:*)", "Bash(npx prisma:*)",
"Bash(npm run dev)", "Bash(npm run dev)",
"Bash(timeout:*)", "Bash(timeout:*)",
"Bash(taskkill:*)", "Bash(taskkill:*)",
"Bash(npx kill-port:*)", "Bash(npx kill-port:*)",
"Bash(docker compose:*)", "Bash(docker compose:*)",
"Bash(curl -I https://fonts.googleapis.com)", "Bash(curl -I https://fonts.googleapis.com)",
"Bash(wsl:*)", "Bash(wsl:*)",
"Read(//c/Users/a931627/.ssh/**)", "Read(//c/Users/a931627/.ssh/**)",
"Bash(ssh-keygen:*)", "Bash(ssh-keygen:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(git remote add:*)", "Bash(git remote add:*)",
"Bash(git push:*)", "Bash(git push:*)",
"Bash(git remote set-url:*)", "Bash(git remote set-url:*)",
"Bash(npm install:*)", "Bash(npm install:*)",
"Bash(npm run build:*)", "Bash(npm run build:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(curl:*)", "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(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:*)", "Bash(pkill:*)",
"Skill(shadcn-ui)", "Skill(shadcn-ui)",
"Bash(find:*)", "Bash(find:*)",
"Bash(ls -la \"/c/Users/User/Documents/QR-master/src/app/\\(main\\)/\\(marketing\\)/\")", "Bash(ls -la \"/c/Users/User/Documents/QR-master/src/app/\\(main\\)/\\(marketing\\)/\")",
"Bash(npx tsc:*)" "Bash(npx tsc:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
}, },
"enabledMcpjsonServers": [ "enabledMcpjsonServers": [
"firecrawl", "firecrawl",
"apify" "apify"
] ]
} }

View File

@@ -1,131 +1,131 @@
# QR Master Internal RevOps Export API # QR Master Internal RevOps Export API
This endpoint exposes read-only QR Master admin/product data for trusted automations such as Hermes cron jobs. This endpoint exposes read-only QR Master admin/product data for trusted automations such as Hermes cron jobs.
## Endpoint ## Endpoint
```http ```http
GET /api/internal/revops-export GET /api/internal/revops-export
Authorization: Bearer <INTERNAL_API_SECRET> Authorization: Bearer <INTERNAL_API_SECRET>
``` ```
The endpoint accepts `INTERNAL_API_SECRET` first, with `CRON_SECRET` as a fallback for compatibility. The endpoint accepts `INTERNAL_API_SECRET` first, with `CRON_SECRET` as a fallback for compatibility.
## Purpose ## Purpose
QR Master only exports structured data. It does **not** perform AI analysis, web research, email drafting, or lead outreach. QR Master only exports structured data. It does **not** perform AI analysis, web research, email drafting, or lead outreach.
Hermes is responsible for: Hermes is responsible for:
- calling this API on a schedule, - calling this API on a schedule,
- researching public websites/domains with its tools, - researching public websites/domains with its tools,
- deciding which leads are worth manual outreach, - deciding which leads are worth manual outreach,
- drafting personalized emails, - drafting personalized emails,
- sending Timo an internal briefing, - sending Timo an internal briefing,
- never auto-sending messages to QR Master users. - never auto-sending messages to QR Master users.
## Query Parameters ## Query Parameters
| Parameter | Example | Notes | | Parameter | Example | Notes |
|---|---|---| |---|---|---|
| `range` | `24h`, `7d`, `30d`, `all` | Defaults to `24h`. | | `range` | `24h`, `7d`, `30d`, `all` | Defaults to `24h`. |
| `from` | `2026-06-30T00:00:00Z` | Optional explicit start. Overrides `range`. | | `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. | | `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. | | `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. | | `allUsers` | `true` | Exports all users up to `limit`; otherwise exports users active/relevant in range. |
## Example Calls ## Example Calls
Daily Hermes lead research context: Daily Hermes lead research context:
```bash ```bash
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
"https://www.qrmaster.net/api/internal/revops-export?range=24h&limit=100" "https://www.qrmaster.net/api/internal/revops-export?range=24h&limit=100"
``` ```
Weekly progress context: Weekly progress context:
```bash ```bash
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
"https://www.qrmaster.net/api/internal/revops-export?range=7d&limit=200" "https://www.qrmaster.net/api/internal/revops-export?range=7d&limit=200"
``` ```
Local development: Local development:
```bash ```bash
curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \ curl -H "Authorization: Bearer $INTERNAL_API_SECRET" \
"http://localhost:3050/api/internal/revops-export?range=24h" "http://localhost:3050/api/internal/revops-export?range=24h"
``` ```
## Response Shape ## Response Shape
Top-level fields: Top-level fields:
```json ```json
{ {
"ok": true, "ok": true,
"generatedAt": "2026-07-01T18:00:00.000Z", "generatedAt": "2026-07-01T18:00:00.000Z",
"endpoint": "/api/internal/revops-export", "endpoint": "/api/internal/revops-export",
"range": { "range": {
"preset": "24h", "preset": "24h",
"from": "2026-06-30T18:00:00.000Z", "from": "2026-06-30T18:00:00.000Z",
"to": "2026-07-01T18:00:00.000Z" "to": "2026-07-01T18:00:00.000Z"
}, },
"overview": {}, "overview": {},
"breakdowns": {}, "breakdowns": {},
"users": [], "users": [],
"leads": [], "leads": [],
"newsletterSubscriptions": [] "newsletterSubscriptions": []
} }
``` ```
Each exported user includes: Each exported user includes:
- identity: `id`, `name`, `email`, `emailDomain`, `createdAt`, `updatedAt` - identity: `id`, `name`, `email`, `emailDomain`, `createdAt`, `updatedAt`
- plan/subscription flags - plan/subscription flags
- attribution fields - attribution fields
- onboarding fields - onboarding fields
- lifecycle scores and timestamps - lifecycle scores and timestamps
- aggregate stats - aggregate stats
- QR codes, content, public path, and scan summaries - QR codes, content, public path, and scan summaries
- integrations - integrations
- recent lifecycle logs - recent lifecycle logs
## Safety Rules for Hermes Jobs ## Safety Rules for Hermes Jobs
Hermes should treat this API as internal source data and follow these rules: Hermes should treat this API as internal source data and follow these rules:
1. Do not send outreach emails directly to leads. 1. Do not send outreach emails directly to leads.
2. Send only an internal briefing/draft pack to Timo. 2. Send only an internal briefing/draft pack to Timo.
3. Use public web research only when it improves personalization. 3. Use public web research only when it improves personalization.
4. Do not tell leads they were researched. 4. Do not tell leads they were researched.
5. Avoid creepy language; frame personalization around their QR Master activity and likely workflow. 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. 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. 7. Prefer business-use signals: QR content type, destination, scans, onboarding use case, domain, company website, landing path.
## Suggested Hermes Cron Jobs ## Suggested Hermes Cron Jobs
### Daily QR Master Lead Research ### Daily QR Master Lead Research
Schedule: daily, evening. Schedule: daily, evening.
Prompt should: Prompt should:
1. call `/api/internal/revops-export?range=24h`, 1. call `/api/internal/revops-export?range=24h`,
2. identify promising leads, 2. identify promising leads,
3. research public company/domain signals, 3. research public company/domain signals,
4. draft personalized outreach emails from Timo, 4. draft personalized outreach emails from Timo,
5. send Timo an internal email briefing, 5. send Timo an internal email briefing,
6. never send emails to leads. 6. never send emails to leads.
### Weekly QR Master Progress Review ### Weekly QR Master Progress Review
Schedule: Sunday evening. Schedule: Sunday evening.
Prompt should: Prompt should:
1. call `/api/internal/revops-export?range=7d`, 1. call `/api/internal/revops-export?range=7d`,
2. summarize users, QR creation, scans, activations, paid/upgrades, 2. summarize users, QR creation, scans, activations, paid/upgrades,
3. identify bottlenecks, 3. identify bottlenecks,
4. suggest next-week goals, 4. suggest next-week goals,
5. email Timo the review. 5. email Timo the review.

View File

@@ -27,12 +27,12 @@ REDIS_URL=redis://redis:6379
# Used for hashing IP addresses in analytics # Used for hashing IP addresses in analytics
IP_SALT=your-ip-salt-here-change-in-production IP_SALT=your-ip-salt-here-change-in-production
# Features # Features
ENABLE_DEMO=false ENABLE_DEMO=false
# SEO Configuration # SEO Configuration
# Set to 'true' in production to allow search engine indexing # Set to 'true' in production to allow search engine indexing
NEXT_PUBLIC_INDEXABLE=true NEXT_PUBLIC_INDEXABLE=true
# Stripe Payment Configuration (Optional - for subscription payments) # Stripe Payment Configuration (Optional - for subscription payments)
# Get your keys from: https://dashboard.stripe.com/apikeys # Get your keys from: https://dashboard.stripe.com/apikeys
@@ -48,3 +48,10 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# Analytics (Optional - PostHog) # Analytics (Optional - PostHog)
NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com 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=

View File

@@ -11,11 +11,11 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
name String? name String?
password String? password String?
image String? image String?
emailVerified DateTime? emailVerified DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -32,56 +32,56 @@ model User {
resetPasswordToken String? @unique resetPasswordToken String? @unique
resetPasswordExpires DateTime? resetPasswordExpires DateTime?
// Retention email tracking // Retention email tracking
activationNudgeSentAt DateTime? activationNudgeSentAt DateTime?
upgradeNudgeSentAt DateTime? upgradeNudgeSentAt DateTime?
thirtyDayNudgeSentAt DateTime? thirtyDayNudgeSentAt DateTime?
// RevOps attribution // RevOps attribution
signupSource String? signupSource String?
signupSourceSelfReported String? signupSourceSelfReported String?
signupMedium String? signupMedium String?
signupCampaign String? signupCampaign String?
signupContent String? signupContent String?
signupTerm String? signupTerm String?
signupReferrer String? signupReferrer String?
signupLandingPath String? signupLandingPath String?
signupFirstSeenAt DateTime? signupFirstSeenAt DateTime?
emailDomain String? emailDomain String?
// Onboarding and qualification // Onboarding and qualification
primaryUseCase String? primaryUseCase String?
primaryGoal String? primaryGoal String?
jobRole String? jobRole String?
companyName String? companyName String?
companyWebsite String? companyWebsite String?
teamSizeBucket String? teamSizeBucket String?
onboardingStartedAt DateTime? onboardingStartedAt DateTime?
sourceConfirmedAt DateTime? sourceConfirmedAt DateTime?
useCaseSelectedAt DateTime? useCaseSelectedAt DateTime?
goalSelectedAt DateTime? goalSelectedAt DateTime?
profileCompletedAt DateTime? profileCompletedAt DateTime?
firstQrCreatedAt DateTime? firstQrCreatedAt DateTime?
firstDynamicQrAt DateTime? firstDynamicQrAt DateTime?
firstStaticQrAt DateTime? firstStaticQrAt DateTime?
firstScanAt DateTime? firstScanAt DateTime?
activationAt DateTime? activationAt DateTime?
onboardingCompletedAt DateTime? onboardingCompletedAt DateTime?
// RevOps scoring // RevOps scoring
fitScore Int @default(0) fitScore Int @default(0)
intentScore Int @default(0) intentScore Int @default(0)
leadScore Int @default(0) leadScore Int @default(0)
lifecycleStage String @default("cold") lifecycleStage String @default("cold")
lastQualifiedAt DateTime? lastQualifiedAt DateTime?
lastScoredAt DateTime? lastScoredAt DateTime?
qrCodes QRCode[] qrCodes QRCode[]
integrations Integration[] integrations Integration[]
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
lifecycleLogs UserLifecycleLog[] lifecycleLogs UserLifecycleLog[]
} }
enum Plan { enum Plan {
FREE FREE
@@ -189,7 +189,7 @@ model QRScan {
@@index([qrId, ts]) @@index([qrId, ts])
} }
model Integration { model Integration {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
provider String provider String
@@ -198,22 +198,35 @@ model Integration {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model UserLifecycleLog { model TiktokIntegration {
id String @id @default(cuid()) id String @id @default(cuid())
userId String accountKey String @unique
fromStage String? openId String
toStage String accessToken String
fitScore Int @default(0) refreshToken String
intentScore Int @default(0) scope String?
leadScore Int @default(0) accessTokenExpiresAt DateTime
reason String? refreshTokenExpiresAt DateTime?
createdAt DateTime @default(now()) 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)
}
model NewsletterSubscription { model NewsletterSubscription {
id String @id @default(cuid()) id String @id @default(cuid())

View File

@@ -1,147 +1,147 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto'; import { ObfuscatedMailto } from '@/components/ui/ObfuscatedMailto';
export const metadata = { export const metadata = {
title: 'Terms of Service | QR Master', 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.', 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: { openGraph: {
title: 'Terms of Service | QR Master', 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.', 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', url: 'https://www.qrmaster.net/terms',
type: 'website', type: 'website',
images: ['/og-image.png'], images: ['/og-image.png'],
}, },
}; };
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="min-h-screen bg-white py-12"> <div className="min-h-screen bg-white py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl"> <div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-4xl">
<div className="mb-8"> <div className="mb-8">
<Link href="/" className="text-primary-600 hover:text-primary-700 font-medium"> <Link href="/" className="text-primary-600 hover:text-primary-700 font-medium">
Back to Home Back to Home
</Link> </Link>
</div> </div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">Terms of Service</h1> <h1 className="text-4xl font-bold text-gray-900 mb-4">Terms of Service</h1>
<p className="text-gray-600 mb-8">Last updated: July 2026</p> <p className="text-gray-600 mb-8">Last updated: July 2026</p>
<div className="prose prose-lg max-w-none"> <div className="prose prose-lg max-w-none">
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">1. Acceptance of Terms</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">1. Acceptance of Terms</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Welcome to QR Master ("we," "our," or "us"). By accessing or using our website and services, you agree to be 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. bound by these Terms of Service. If you do not agree to these terms, please do not use our services.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">2. Description of Service</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">2. Description of Service</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
QR Master provides a platform for creating and managing static and dynamic QR codes, including scan 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 analytics, customization tools, and bulk creation features. We may add, change, or remove features at any
time. time.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">3. Accounts</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">3. Accounts</h2>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account.</li> <li>You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account.</li>
<li>You must be at least 16 years old to use our services.</li> <li>You must be at least 16 years old to use our services.</li>
<li>You must provide accurate account information and keep it up to date.</li> <li>You must provide accurate account information and keep it up to date.</li>
<li>You may delete your account at any time from your account settings.</li> <li>You may delete your account at any time from your account settings.</li>
</ul> </ul>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">4. Subscriptions &amp; Payments</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">4. Subscriptions &amp; Payments</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
QR Master offers FREE, PRO, and BUSINESS subscription plans. Paid subscriptions are billed through Stripe 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 and renew automatically until cancelled. You can manage or cancel your subscription at any time through
the customer billing portal in your account settings. the customer billing portal in your account settings.
</p> </p>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Fees are non-refundable except where required by applicable law. Downgrading or cancelling a plan may 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. affect the availability of dynamic QR codes and other paid features.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">5. Acceptable Use</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">5. Acceptable Use</h2>
<p className="text-gray-700 mb-4">You agree not to use QR Master to:</p> <p className="text-gray-700 mb-4">You agree not to use QR Master to:</p>
<ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2"> <ul className="list-disc pl-6 mb-4 text-gray-700 space-y-2">
<li>Create or distribute QR codes linking to illegal, fraudulent, or harmful content</li> <li>Create or distribute QR codes linking to illegal, fraudulent, or harmful content</li>
<li>Spam, phish, or mislead scanners about the destination of a QR code</li> <li>Spam, phish, or mislead scanners about the destination of a QR code</li>
<li>Attempt to circumvent rate limits, security measures, or plan restrictions</li> <li>Attempt to circumvent rate limits, security measures, or plan restrictions</li>
<li>Interfere with or disrupt the integrity or performance of our services</li> <li>Interfere with or disrupt the integrity or performance of our services</li>
<li>Reverse engineer or resell access to our services without authorization</li> <li>Reverse engineer or resell access to our services without authorization</li>
</ul> </ul>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
We reserve the right to disable QR codes or accounts that violate this policy. We reserve the right to disable QR codes or accounts that violate this policy.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">6. Intellectual Property</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">6. Intellectual Property</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
QR Master and its original content, features, and functionality are owned by us and are protected by 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 copyright, trademark, and other intellectual property laws. You retain all rights to the content and
destinations you associate with your own QR codes. destinations you associate with your own QR codes.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">7. Termination</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">7. Termination</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
We may suspend or terminate your access to QR Master at any time if you violate these terms or misuse our 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. services. You may stop using our services and delete your account at any time.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">8. Disclaimers &amp; Limitation of Liability</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">8. Disclaimers &amp; Limitation of Liability</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
Our services are provided "as is" and "as available" without warranties of any kind. We do not control 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 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 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. consequential damages arising from your use of the services.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">9. Changes to These Terms</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">9. Changes to These Terms</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
We may update these Terms of Service from time to time. We will update the "Last updated" date above when 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 changes are made. Continued use of our services after changes take effect constitutes acceptance of the
revised terms. revised terms.
</p> </p>
</section> </section>
<section className="mb-8"> <section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">10. Contact Us</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">10. Contact Us</h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">
If you have questions about these Terms of Service, please contact us: If you have questions about these Terms of Service, please contact us:
</p> </p>
<div className="bg-gray-50 p-6 rounded-lg"> <div className="bg-gray-50 p-6 rounded-lg">
<p className="text-gray-700 mb-2"> <p className="text-gray-700 mb-2">
<strong>Email:</strong>{' '} <strong>Email:</strong>{' '}
<ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700" /> <ObfuscatedMailto email="support@qrmaster.net" className="text-primary-600 hover:text-primary-700" />
</p> </p>
<p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p> <p className="text-gray-700 mb-2"><strong>Website:</strong> <a href="/" className="text-primary-600 hover:text-primary-700">qrmaster.net</a></p>
</div> </div>
</section> </section>
</div> </div>
<div className="mt-12 pt-8 border-t border-gray-200"> <div className="mt-12 pt-8 border-t border-gray-200">
<p className="text-gray-600 text-center"> <p className="text-gray-600 text-center">
<Link href="/" className="text-primary-600 hover:text-primary-700"> <Link href="/" className="text-primary-600 hover:text-primary-700">
Back to Home Back to Home
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,463 +1,463 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { getEmailDomain } from '@/lib/revops'; import { getEmailDomain } from '@/lib/revops';
import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server'; import { getMetricSnapshot, getUpgradeCandidateBadges } from '@/lib/revops-server';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
type RangePreset = '24h' | '7d' | '30d' | 'all'; type RangePreset = '24h' | '7d' | '30d' | 'all';
const DEFAULT_LIMIT = 100; const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500; const MAX_LIMIT = 500;
function isAuthorized(request: NextRequest): boolean { function isAuthorized(request: NextRequest): boolean {
const authHeader = request.headers.get('authorization'); const authHeader = request.headers.get('authorization');
const token = process.env.INTERNAL_API_SECRET || process.env.CRON_SECRET; const token = process.env.INTERNAL_API_SECRET || process.env.CRON_SECRET;
if (!token) return false; if (!token) return false;
return authHeader === `Bearer ${token}`; return authHeader === `Bearer ${token}`;
} }
function parsePositiveInt(value: string | null, fallback: number, max: number) { function parsePositiveInt(value: string | null, fallback: number, max: number) {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback; if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.floor(parsed), max); return Math.min(Math.floor(parsed), max);
} }
function parseDate(value: string | null): Date | null { function parseDate(value: string | null): Date | null {
if (!value) return null; if (!value) return null;
const parsed = new Date(value); const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed; return Number.isNaN(parsed.getTime()) ? null : parsed;
} }
function resolveDateRange(request: NextRequest) { function resolveDateRange(request: NextRequest) {
const now = new Date(); const now = new Date();
const explicitFrom = parseDate(request.nextUrl.searchParams.get('from')); const explicitFrom = parseDate(request.nextUrl.searchParams.get('from'));
const explicitTo = parseDate(request.nextUrl.searchParams.get('to')); const explicitTo = parseDate(request.nextUrl.searchParams.get('to'));
const range = (request.nextUrl.searchParams.get('range') || '24h') as RangePreset; const range = (request.nextUrl.searchParams.get('range') || '24h') as RangePreset;
if (explicitFrom || explicitTo) { if (explicitFrom || explicitTo) {
return { return {
from: explicitFrom, from: explicitFrom,
to: explicitTo ?? now, to: explicitTo ?? now,
preset: 'custom', preset: 'custom',
}; };
} }
if (range === 'all') { if (range === 'all') {
return { from: null, to: now, preset: range }; return { from: null, to: now, preset: range };
} }
const hours = range === '7d' ? 24 * 7 : range === '30d' ? 24 * 30 : 24; const hours = range === '7d' ? 24 * 7 : range === '30d' ? 24 * 30 : 24;
return { return {
from: new Date(now.getTime() - hours * 60 * 60 * 1000), from: new Date(now.getTime() - hours * 60 * 60 * 1000),
to: now, to: now,
preset: range, preset: range,
}; };
} }
function withinRange(date: Date | null | undefined, from: Date | null, to: Date) { function withinRange(date: Date | null | undefined, from: Date | null, to: Date) {
if (!date) return false; if (!date) return false;
return (!from || date >= from) && date <= to; return (!from || date >= from) && date <= to;
} }
function jsonDate(value: Date | null | undefined) { function jsonDate(value: Date | null | undefined) {
return value ? value.toISOString() : null; return value ? value.toISOString() : null;
} }
function safeJson(value: unknown) { function safeJson(value: unknown) {
return value ?? null; return value ?? null;
} }
function summarizeScans(scans: Array<{ function summarizeScans(scans: Array<{
ts: Date; ts: Date;
isUnique: boolean; isUnique: boolean;
country: string | null; country: string | null;
device: string | null; device: string | null;
os: string | null; os: string | null;
referrer: string | null; referrer: string | null;
utmSource: string | null; utmSource: string | null;
utmMedium: string | null; utmMedium: string | null;
utmCampaign: string | null; utmCampaign: string | null;
}>) { }>) {
const countries = new Map<string, number>(); const countries = new Map<string, number>();
const devices = new Map<string, number>(); const devices = new Map<string, number>();
const os = new Map<string, number>(); const os = new Map<string, number>();
const referrers = new Map<string, number>(); const referrers = new Map<string, number>();
const utmSources = new Map<string, number>(); const utmSources = new Map<string, number>();
const utmCampaigns = new Map<string, number>(); const utmCampaigns = new Map<string, number>();
const scansByDay = new Map<string, number>(); const scansByDay = new Map<string, number>();
for (const scan of scans) { for (const scan of scans) {
const day = scan.ts.toISOString().slice(0, 10); const day = scan.ts.toISOString().slice(0, 10);
scansByDay.set(day, (scansByDay.get(day) || 0) + 1); scansByDay.set(day, (scansByDay.get(day) || 0) + 1);
const increment = (map: Map<string, number>, key?: string | null) => { const increment = (map: Map<string, number>, key?: string | null) => {
if (!key) return; if (!key) return;
map.set(key, (map.get(key) || 0) + 1); map.set(key, (map.get(key) || 0) + 1);
}; };
increment(countries, scan.country); increment(countries, scan.country);
increment(devices, scan.device); increment(devices, scan.device);
increment(os, scan.os); increment(os, scan.os);
increment(referrers, scan.referrer); increment(referrers, scan.referrer);
increment(utmSources, scan.utmSource); increment(utmSources, scan.utmSource);
increment(utmCampaigns, scan.utmCampaign); increment(utmCampaigns, scan.utmCampaign);
} }
const top = (map: Map<string, number>) => const top = (map: Map<string, number>) =>
Array.from(map.entries()) Array.from(map.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10) .slice(0, 10)
.map(([key, count]) => ({ key, count })); .map(([key, count]) => ({ key, count }));
return { return {
total: scans.length, total: scans.length,
unique: scans.filter((scan) => scan.isUnique).length, unique: scans.filter((scan) => scan.isUnique).length,
firstScanAt: jsonDate(scans[0]?.ts), firstScanAt: jsonDate(scans[0]?.ts),
lastScanAt: jsonDate(scans[scans.length - 1]?.ts), lastScanAt: jsonDate(scans[scans.length - 1]?.ts),
byDay: Array.from(scansByDay.entries()).map(([date, count]) => ({ date, count })), byDay: Array.from(scansByDay.entries()).map(([date, count]) => ({ date, count })),
topCountries: top(countries), topCountries: top(countries),
topDevices: top(devices), topDevices: top(devices),
topOperatingSystems: top(os), topOperatingSystems: top(os),
topReferrers: top(referrers), topReferrers: top(referrers),
topUtmSources: top(utmSources), topUtmSources: top(utmSources),
topUtmCampaigns: top(utmCampaigns), topUtmCampaigns: top(utmCampaigns),
}; };
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
if (!isAuthorized(request)) { if (!isAuthorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { from, to, preset } = resolveDateRange(request); const { from, to, preset } = resolveDateRange(request);
const limit = parsePositiveInt(request.nextUrl.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT); const limit = parsePositiveInt(request.nextUrl.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT);
const includeAllUsers = request.nextUrl.searchParams.get('allUsers') === 'true' || preset === 'all'; const includeAllUsers = request.nextUrl.searchParams.get('allUsers') === 'true' || preset === 'all';
const users = await db.user.findMany({ const users = await db.user.findMany({
where: includeAllUsers || !from ? undefined : { where: includeAllUsers || !from ? undefined : {
OR: [ OR: [
{ createdAt: { gte: from, lte: to } }, { createdAt: { gte: from, lte: to } },
{ updatedAt: { gte: from, lte: to } }, { updatedAt: { gte: from, lte: to } },
{ firstQrCreatedAt: { gte: from, lte: to } }, { firstQrCreatedAt: { gte: from, lte: to } },
{ firstDynamicQrAt: { gte: from, lte: to } }, { firstDynamicQrAt: { gte: from, lte: to } },
{ firstStaticQrAt: { gte: from, lte: to } }, { firstStaticQrAt: { gte: from, lte: to } },
{ firstScanAt: { gte: from, lte: to } }, { firstScanAt: { gte: from, lte: to } },
{ activationAt: { gte: from, lte: to } }, { activationAt: { gte: from, lte: to } },
{ lastQualifiedAt: { gte: from, lte: to } }, { lastQualifiedAt: { gte: from, lte: to } },
{ lastScoredAt: { gte: from, lte: to } }, { lastScoredAt: { gte: from, lte: to } },
{ qrCodes: { some: { createdAt: { gte: from, lte: to } } } }, { qrCodes: { some: { createdAt: { gte: from, lte: to } } } },
{ qrCodes: { some: { scans: { some: { ts: { gte: from, lte: to } } } } } }, { qrCodes: { some: { scans: { some: { ts: { gte: from, lte: to } } } } } },
], ],
}, },
orderBy: [ orderBy: [
{ leadScore: 'desc' }, { leadScore: 'desc' },
{ createdAt: 'desc' }, { createdAt: 'desc' },
], ],
take: limit, take: limit,
select: { select: {
id: true, id: true,
email: true, email: true,
name: true, name: true,
image: true, image: true,
emailVerified: true, emailVerified: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
plan: true, plan: true,
stripeCustomerId: true, stripeCustomerId: true,
stripeSubscriptionId: true, stripeSubscriptionId: true,
stripePriceId: true, stripePriceId: true,
stripeCurrentPeriodEnd: true, stripeCurrentPeriodEnd: true,
signupSource: true, signupSource: true,
signupSourceSelfReported: true, signupSourceSelfReported: true,
signupMedium: true, signupMedium: true,
signupCampaign: true, signupCampaign: true,
signupContent: true, signupContent: true,
signupTerm: true, signupTerm: true,
signupReferrer: true, signupReferrer: true,
signupLandingPath: true, signupLandingPath: true,
signupFirstSeenAt: true, signupFirstSeenAt: true,
emailDomain: true, emailDomain: true,
primaryUseCase: true, primaryUseCase: true,
primaryGoal: true, primaryGoal: true,
jobRole: true, jobRole: true,
companyName: true, companyName: true,
companyWebsite: true, companyWebsite: true,
teamSizeBucket: true, teamSizeBucket: true,
onboardingStartedAt: true, onboardingStartedAt: true,
sourceConfirmedAt: true, sourceConfirmedAt: true,
useCaseSelectedAt: true, useCaseSelectedAt: true,
goalSelectedAt: true, goalSelectedAt: true,
profileCompletedAt: true, profileCompletedAt: true,
firstQrCreatedAt: true, firstQrCreatedAt: true,
firstDynamicQrAt: true, firstDynamicQrAt: true,
firstStaticQrAt: true, firstStaticQrAt: true,
firstScanAt: true, firstScanAt: true,
activationAt: true, activationAt: true,
onboardingCompletedAt: true, onboardingCompletedAt: true,
fitScore: true, fitScore: true,
intentScore: true, intentScore: true,
leadScore: true, leadScore: true,
lifecycleStage: true, lifecycleStage: true,
lastQualifiedAt: true, lastQualifiedAt: true,
lastScoredAt: true, lastScoredAt: true,
qrCodes: { qrCodes: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 50, take: 50,
select: { select: {
id: true, id: true,
title: true, title: true,
type: true, type: true,
contentType: true, contentType: true,
content: true, content: true,
tags: true, tags: true,
status: true, status: true,
style: true, style: true,
slug: true, slug: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
scans: { scans: {
orderBy: { ts: 'asc' }, orderBy: { ts: 'asc' },
take: 500, take: 500,
select: { select: {
id: true, id: true,
ts: true, ts: true,
device: true, device: true,
os: true, os: true,
country: true, country: true,
referrer: true, referrer: true,
utmSource: true, utmSource: true,
utmMedium: true, utmMedium: true,
utmCampaign: true, utmCampaign: true,
isUnique: true, isUnique: true,
}, },
}, },
_count: { _count: {
select: { scans: true }, select: { scans: true },
}, },
}, },
}, },
lifecycleLogs: { lifecycleLogs: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 10, take: 10,
select: { select: {
fromStage: true, fromStage: true,
toStage: true, toStage: true,
fitScore: true, fitScore: true,
intentScore: true, intentScore: true,
leadScore: true, leadScore: true,
reason: true, reason: true,
createdAt: true, createdAt: true,
}, },
}, },
integrations: { integrations: {
select: { select: {
provider: true, provider: true,
status: true, status: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}, },
}, },
}); });
const leads = await db.lead.findMany({ const leads = await db.lead.findMany({
where: from ? { createdAt: { gte: from, lte: to } } : undefined, where: from ? { createdAt: { gte: from, lte: to } } : undefined,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
select: { select: {
id: true, id: true,
email: true, email: true,
source: true, source: true,
reprintCost: true, reprintCost: true,
updatesPerYear: true, updatesPerYear: true,
annualSavings: true, annualSavings: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}); });
const newsletterSubscriptions = await db.newsletterSubscription.findMany({ const newsletterSubscriptions = await db.newsletterSubscription.findMany({
where: from ? { createdAt: { gte: from, lte: to } } : undefined, where: from ? { createdAt: { gte: from, lte: to } } : undefined,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
select: { select: {
id: true, id: true,
email: true, email: true,
source: true, source: true,
status: true, status: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}); });
const exportedUsers = users.map((user) => { const exportedUsers = users.map((user) => {
const metrics = getMetricSnapshot(user.qrCodes); const metrics = getMetricSnapshot(user.qrCodes);
const allScans = user.qrCodes.flatMap((qr) => qr.scans).sort((a, b) => a.ts.getTime() - b.ts.getTime()); 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)); const rangeScans = allScans.filter((scan) => withinRange(scan.ts, from, to));
return { return {
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
emailDomain: user.emailDomain || getEmailDomain(user.email), emailDomain: user.emailDomain || getEmailDomain(user.email),
image: user.image, image: user.image,
emailVerified: jsonDate(user.emailVerified), emailVerified: jsonDate(user.emailVerified),
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(), updatedAt: user.updatedAt.toISOString(),
plan: user.plan, plan: user.plan,
stripe: { stripe: {
hasCustomer: Boolean(user.stripeCustomerId), hasCustomer: Boolean(user.stripeCustomerId),
hasSubscription: Boolean(user.stripeSubscriptionId), hasSubscription: Boolean(user.stripeSubscriptionId),
priceId: user.stripePriceId, priceId: user.stripePriceId,
currentPeriodEnd: jsonDate(user.stripeCurrentPeriodEnd), currentPeriodEnd: jsonDate(user.stripeCurrentPeriodEnd),
}, },
attribution: { attribution: {
signupSource: user.signupSource, signupSource: user.signupSource,
signupSourceSelfReported: user.signupSourceSelfReported, signupSourceSelfReported: user.signupSourceSelfReported,
signupMedium: user.signupMedium, signupMedium: user.signupMedium,
signupCampaign: user.signupCampaign, signupCampaign: user.signupCampaign,
signupContent: user.signupContent, signupContent: user.signupContent,
signupTerm: user.signupTerm, signupTerm: user.signupTerm,
signupReferrer: user.signupReferrer, signupReferrer: user.signupReferrer,
signupLandingPath: user.signupLandingPath, signupLandingPath: user.signupLandingPath,
signupFirstSeenAt: jsonDate(user.signupFirstSeenAt), signupFirstSeenAt: jsonDate(user.signupFirstSeenAt),
}, },
onboarding: { onboarding: {
primaryUseCase: user.primaryUseCase, primaryUseCase: user.primaryUseCase,
primaryGoal: user.primaryGoal, primaryGoal: user.primaryGoal,
jobRole: user.jobRole, jobRole: user.jobRole,
companyName: user.companyName, companyName: user.companyName,
companyWebsite: user.companyWebsite, companyWebsite: user.companyWebsite,
teamSizeBucket: user.teamSizeBucket, teamSizeBucket: user.teamSizeBucket,
onboardingStartedAt: jsonDate(user.onboardingStartedAt), onboardingStartedAt: jsonDate(user.onboardingStartedAt),
sourceConfirmedAt: jsonDate(user.sourceConfirmedAt), sourceConfirmedAt: jsonDate(user.sourceConfirmedAt),
useCaseSelectedAt: jsonDate(user.useCaseSelectedAt), useCaseSelectedAt: jsonDate(user.useCaseSelectedAt),
goalSelectedAt: jsonDate(user.goalSelectedAt), goalSelectedAt: jsonDate(user.goalSelectedAt),
profileCompletedAt: jsonDate(user.profileCompletedAt), profileCompletedAt: jsonDate(user.profileCompletedAt),
onboardingCompletedAt: jsonDate(user.onboardingCompletedAt), onboardingCompletedAt: jsonDate(user.onboardingCompletedAt),
}, },
lifecycle: { lifecycle: {
fitScore: user.fitScore, fitScore: user.fitScore,
intentScore: user.intentScore, intentScore: user.intentScore,
leadScore: user.leadScore, leadScore: user.leadScore,
lifecycleStage: user.lifecycleStage, lifecycleStage: user.lifecycleStage,
firstQrCreatedAt: jsonDate(user.firstQrCreatedAt), firstQrCreatedAt: jsonDate(user.firstQrCreatedAt),
firstDynamicQrAt: jsonDate(user.firstDynamicQrAt), firstDynamicQrAt: jsonDate(user.firstDynamicQrAt),
firstStaticQrAt: jsonDate(user.firstStaticQrAt), firstStaticQrAt: jsonDate(user.firstStaticQrAt),
firstScanAt: jsonDate(user.firstScanAt), firstScanAt: jsonDate(user.firstScanAt),
activationAt: jsonDate(user.activationAt), activationAt: jsonDate(user.activationAt),
lastQualifiedAt: jsonDate(user.lastQualifiedAt), lastQualifiedAt: jsonDate(user.lastQualifiedAt),
lastScoredAt: jsonDate(user.lastScoredAt), lastScoredAt: jsonDate(user.lastScoredAt),
upgradeBadges: getUpgradeCandidateBadges(user, metrics), upgradeBadges: getUpgradeCandidateBadges(user, metrics),
logs: user.lifecycleLogs.map((log) => ({ logs: user.lifecycleLogs.map((log) => ({
...log, ...log,
createdAt: log.createdAt.toISOString(), createdAt: log.createdAt.toISOString(),
})), })),
}, },
stats: { stats: {
qrCount: metrics.qrCount, qrCount: metrics.qrCount,
dynamicQrCount: metrics.dynamicQrCount, dynamicQrCount: metrics.dynamicQrCount,
staticQrCount: metrics.qrCount - metrics.dynamicQrCount, staticQrCount: metrics.qrCount - metrics.dynamicQrCount,
contentTypeCount: metrics.contentTypeCount, contentTypeCount: metrics.contentTypeCount,
businessishTypeCount: metrics.businessishTypeCount, businessishTypeCount: metrics.businessishTypeCount,
scanCount: metrics.scanCount, scanCount: metrics.scanCount,
scansInRange: rangeScans.length, scansInRange: rangeScans.length,
uniqueScansInRange: rangeScans.filter((scan) => scan.isUnique).length, uniqueScansInRange: rangeScans.filter((scan) => scan.isUnique).length,
}, },
scans: summarizeScans(allScans), scans: summarizeScans(allScans),
scansInRange: summarizeScans(rangeScans), scansInRange: summarizeScans(rangeScans),
qrCodes: user.qrCodes.map((qr) => { qrCodes: user.qrCodes.map((qr) => {
const qrScansInRange = qr.scans.filter((scan) => withinRange(scan.ts, from, to)); const qrScansInRange = qr.scans.filter((scan) => withinRange(scan.ts, from, to));
return { return {
id: qr.id, id: qr.id,
title: qr.title, title: qr.title,
type: qr.type, type: qr.type,
contentType: qr.contentType, contentType: qr.contentType,
content: safeJson(qr.content), content: safeJson(qr.content),
tags: qr.tags, tags: qr.tags,
status: qr.status, status: qr.status,
style: safeJson(qr.style), style: safeJson(qr.style),
slug: qr.slug, slug: qr.slug,
publicPath: `/r/${qr.slug}`, publicPath: `/r/${qr.slug}`,
createdAt: qr.createdAt.toISOString(), createdAt: qr.createdAt.toISOString(),
updatedAt: qr.updatedAt.toISOString(), updatedAt: qr.updatedAt.toISOString(),
scanCount: qr._count.scans, scanCount: qr._count.scans,
scansInRange: qrScansInRange.length, scansInRange: qrScansInRange.length,
firstScanAt: jsonDate(qr.scans[0]?.ts), firstScanAt: jsonDate(qr.scans[0]?.ts),
lastScanAt: jsonDate(qr.scans[qr.scans.length - 1]?.ts), lastScanAt: jsonDate(qr.scans[qr.scans.length - 1]?.ts),
scanSummary: summarizeScans(qr.scans), scanSummary: summarizeScans(qr.scans),
scanSummaryInRange: summarizeScans(qrScansInRange), scanSummaryInRange: summarizeScans(qrScansInRange),
}; };
}), }),
integrations: user.integrations.map((integration) => ({ integrations: user.integrations.map((integration) => ({
...integration, ...integration,
createdAt: integration.createdAt.toISOString(), createdAt: integration.createdAt.toISOString(),
updatedAt: integration.updatedAt.toISOString(), updatedAt: integration.updatedAt.toISOString(),
})), })),
}; };
}); });
const allQrs = exportedUsers.flatMap((user) => user.qrCodes.map((qr) => ({ userId: user.id, ...qr }))); 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 qrsInRange = allQrs.filter((qr) => withinRange(new Date(qr.createdAt), from, to));
const scansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.scansInRange, 0); const scansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.scansInRange, 0);
const uniqueScansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.uniqueScansInRange, 0); const uniqueScansInRange = exportedUsers.reduce((sum, user) => sum + user.stats.uniqueScansInRange, 0);
const contentTypeCounts = new Map<string, number>(); const contentTypeCounts = new Map<string, number>();
for (const qr of allQrs) { for (const qr of allQrs) {
contentTypeCounts.set(qr.contentType, (contentTypeCounts.get(qr.contentType) || 0) + 1); contentTypeCounts.set(qr.contentType, (contentTypeCounts.get(qr.contentType) || 0) + 1);
} }
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
endpoint: '/api/internal/revops-export', endpoint: '/api/internal/revops-export',
range: { range: {
preset, preset,
from: jsonDate(from), from: jsonDate(from),
to: to.toISOString(), to: to.toISOString(),
}, },
limits: { limits: {
requestedUserLimit: limit, requestedUserLimit: limit,
maxUserLimit: MAX_LIMIT, maxUserLimit: MAX_LIMIT,
qrLimitPerUser: 50, qrLimitPerUser: 50,
scanLimitPerQr: 500, scanLimitPerQr: 500,
}, },
overview: { overview: {
exportedUsers: exportedUsers.length, exportedUsers: exportedUsers.length,
newUsersInRange: exportedUsers.filter((user) => withinRange(new Date(user.createdAt), from, to)).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, 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, paidUsers: exportedUsers.filter((user) => user.plan === 'PRO' || user.plan === 'BUSINESS').length,
hotUsers: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'hot').length, hotUsers: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'hot').length,
upgradeCandidates: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'upgrade_candidate').length, upgradeCandidates: exportedUsers.filter((user) => user.lifecycle.lifecycleStage === 'upgrade_candidate').length,
qrCodesTotal: allQrs.length, qrCodesTotal: allQrs.length,
qrCodesCreatedInRange: qrsInRange.length, qrCodesCreatedInRange: qrsInRange.length,
scansInRange, scansInRange,
uniqueScansInRange, uniqueScansInRange,
reprintCalculatorLeadsInRange: leads.length, reprintCalculatorLeadsInRange: leads.length,
newsletterSubscriptionsInRange: newsletterSubscriptions.length, newsletterSubscriptionsInRange: newsletterSubscriptions.length,
}, },
breakdowns: { breakdowns: {
qrContentTypes: Array.from(contentTypeCounts.entries()) qrContentTypes: Array.from(contentTypeCounts.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([contentType, count]) => ({ contentType, count })), .map(([contentType, count]) => ({ contentType, count })),
}, },
users: exportedUsers, users: exportedUsers,
leads: leads.map((lead) => ({ leads: leads.map((lead) => ({
...lead, ...lead,
createdAt: lead.createdAt.toISOString(), createdAt: lead.createdAt.toISOString(),
updatedAt: lead.updatedAt.toISOString(), updatedAt: lead.updatedAt.toISOString(),
})), })),
newsletterSubscriptions: newsletterSubscriptions.map((subscription) => ({ newsletterSubscriptions: newsletterSubscriptions.map((subscription) => ({
...subscription, ...subscription,
createdAt: subscription.createdAt.toISOString(), createdAt: subscription.createdAt.toISOString(),
updatedAt: subscription.updatedAt.toISOString(), updatedAt: subscription.updatedAt.toISOString(),
})), })),
}); });
} catch (error) { } catch (error) {
console.error('Error exporting internal RevOps data:', error); console.error('Error exporting internal RevOps data:', error);
return NextResponse.json({ error: 'Failed to export RevOps data' }, { status: 500 }); return NextResponse.json({ error: 'Failed to export RevOps data' }, { status: 500 });
} }
} }

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 });
}
}

View File

@@ -1,442 +1,442 @@
{ {
"nav": { "nav": {
"features": "Funktionen", "features": "Funktionen",
"pricing": "Preise", "pricing": "Preise",
"faq": "FAQ", "faq": "FAQ",
"blog": "Blog", "blog": "Blog",
"login": "Anmelden", "login": "Anmelden",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"about": "Über uns", "about": "Über uns",
"contact": "Kontakt", "contact": "Kontakt",
"signup": "Registrieren", "signup": "Registrieren",
"learn": "Lernen", "learn": "Lernen",
"create_qr": "QR erstellen", "create_qr": "QR erstellen",
"bulk_creation": "Massen-Erstellung", "bulk_creation": "Massen-Erstellung",
"analytics": "Analytik", "analytics": "Analytik",
"settings": "Einstellungen", "settings": "Einstellungen",
"cta": "Kostenlos starten", "cta": "Kostenlos starten",
"tools": "Kostenlose Tools", "tools": "Kostenlose Tools",
"all_free": "Alle Generatoren sind 100% kostenlos", "all_free": "Alle Generatoren sind 100% kostenlos",
"resources": "Ressourcen", "resources": "Ressourcen",
"all_industries": "Branchen" "all_industries": "Branchen"
}, },
"hero": { "hero": {
"badge": "Kostenloser QR-Code-Generator", "badge": "Kostenloser QR-Code-Generator",
"title": "Erstellen Sie QR-Codes, die überall funktionieren", "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.", "subtitle": "Generieren Sie statische und dynamische QR-Codes mit Tracking, individuellem Branding und Massen-Erstellung. Kostenlos für immer.",
"features": [ "features": [
"Keine Kreditkarte zum Starten erforderlich", "Keine Kreditkarte zum Starten erforderlich",
"QR-Codes für immer kostenlos erstellen", "QR-Codes für immer kostenlos erstellen",
"Erweiterte Verfolgung und Analytik", "Erweiterte Verfolgung und Analytik",
"Individuelle Farben und Stile" "Individuelle Farben und Stile"
], ],
"cta_primary": "QR-Code kostenlos erstellen", "cta_primary": "QR-Code kostenlos erstellen",
"cta_secondary": "Preise ansehen", "cta_secondary": "Preise ansehen",
"engagement_badge": "Kostenlos für immer", "engagement_badge": "Kostenlos für immer",
"get_started": "Loslegen", "get_started": "Loslegen",
"view_full_pricing": "Alle Preisdetails ansehen →" "view_full_pricing": "Alle Preisdetails ansehen →"
}, },
"trust": { "trust": {
"users": "Aktive Nutzer", "users": "Aktive Nutzer",
"codes": "QR-Codes erstellt", "codes": "QR-Codes erstellt",
"scans": "Scans verfolgt", "scans": "Scans verfolgt",
"countries": "Länder" "countries": "Länder"
}, },
"industries": { "industries": {
"restaurant": "Restaurant-Kette", "restaurant": "Restaurant-Kette",
"tech": "Tech-Startup", "tech": "Tech-Startup",
"realestate": "Immobilien", "realestate": "Immobilien",
"events": "Event-Agentur", "events": "Event-Agentur",
"retail": "Einzelhandel", "retail": "Einzelhandel",
"healthcare": "Gesundheitswesen" "healthcare": "Gesundheitswesen"
}, },
"templates": { "templates": {
"title": "Mit einer Vorlage beginnen", "title": "Mit einer Vorlage beginnen",
"restaurant": "Restaurant-Menü", "restaurant": "Restaurant-Menü",
"business": "Visitenkarte", "business": "Visitenkarte",
"vcard": "Kontaktkarte", "vcard": "Kontaktkarte",
"event": "Event-Ticket", "event": "Event-Ticket",
"use_template": "Vorlage verwenden →" "use_template": "Vorlage verwenden →"
}, },
"generator": { "generator": {
"title": "Sofortiger QR-Code-Generator", "title": "Sofortiger QR-Code-Generator",
"url_placeholder": "Geben Sie hier Ihre URL ein...", "url_placeholder": "Geben Sie hier Ihre URL ein...",
"foreground": "Vordergrund", "foreground": "Vordergrund",
"background": "Hintergrund", "background": "Hintergrund",
"corners": "Ecken", "corners": "Ecken",
"size": "Größe", "size": "Größe",
"contrast_good": "Guter Kontrast", "contrast_good": "Guter Kontrast",
"download_svg": "SVG herunterladen", "download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen", "download_png": "PNG herunterladen",
"save_track": "Speichern & Verfolgen", "save_track": "Speichern & Verfolgen",
"live_preview": "Live-Vorschau", "live_preview": "Live-Vorschau",
"demo_note": "Dies ist ein Demo-QR-Code" "demo_note": "Dies ist ein Demo-QR-Code"
}, },
"static_vs_dynamic": { "static_vs_dynamic": {
"title": "Warum dynamische QR-Codes Ihnen Geld sparen", "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.", "description": "Hören Sie auf, Materialien neu zu drucken. Wechseln Sie Ziele sofort und verfolgen Sie jeden Scan.",
"static": { "static": {
"title": "Statische QR-Codes", "title": "Statische QR-Codes",
"subtitle": "Immer kostenlos", "subtitle": "Immer kostenlos",
"description": "Perfekt für permanente Inhalte, die sich nie ändern", "description": "Perfekt für permanente Inhalte, die sich nie ändern",
"features": [ "features": [
"Inhalt kann nicht bearbeitet werden", "Inhalt kann nicht bearbeitet werden",
"Keine Scan-Verfolgung", "Keine Scan-Verfolgung",
"Funktioniert für immer", "Funktioniert für immer",
"Kein Konto erforderlich" "Kein Konto erforderlich"
] ]
}, },
"dynamic": { "dynamic": {
"title": "Dynamische QR-Codes", "title": "Dynamische QR-Codes",
"subtitle": "Empfohlen", "subtitle": "Empfohlen",
"description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen", "description": "Volle Kontrolle mit Tracking- und Bearbeitungsfunktionen",
"features": [ "features": [
"Inhalt jederzeit bearbeiten", "Inhalt jederzeit bearbeiten",
"Erweiterte Analytik", "Erweiterte Analytik",
"Individuelles Branding", "Individuelles Branding",
"Bulk-Operationen" "Bulk-Operationen"
] ]
} }
}, },
"features": { "features": {
"title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen", "title": "Alles was Sie brauchen, um professionelle QR-Codes zu erstellen",
"analytics": { "analytics": {
"title": "Erweiterte Analytik", "title": "Erweiterte Analytik",
"description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken." "description": "Verfolgen Sie Scans, Standorte, Geräte und Nutzerverhalten mit detaillierten Einblicken."
}, },
"customization": { "customization": {
"title": "Vollständige Anpassung", "title": "Vollständige Anpassung",
"description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen." "description": "Branden Sie Ihre QR-Codes mit individuellen Farben, Logos und Styling-Optionen."
}, },
"unlimited": { "unlimited": {
"title": "Unbegrenzte statische QR-Codes", "title": "Unbegrenzte statische QR-Codes",
"description": "Erstellen Sie so viele statische QR-Codes wie Sie benötigen. Kostenlos für immer, ohne Limits." "description": "Erstellen Sie so viele statische QR-Codes wie Sie benötigen. Kostenlos für immer, ohne Limits."
}, },
"bulk": { "bulk": {
"title": "Bulk-Operationen", "title": "Bulk-Operationen",
"description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung." "description": "Erstellen Sie hunderte von QR-Codes auf einmal mit CSV-Import und Batch-Verarbeitung."
}, },
"integrations": { "integrations": {
"title": "Integrationen", "title": "Integrationen",
"description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools." "description": "Verbinden Sie sich mit Zapier, Airtable, Google Sheets und weiteren beliebten Tools."
}, },
"api": { "api": {
"title": "Entwickler-API", "title": "Entwickler-API",
"description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API." "description": "Integrieren Sie QR-Code-Generierung in Ihre Anwendungen mit unserer REST-API."
}, },
"support": { "support": {
"title": "24/7 Support", "title": "24/7 Support",
"description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team." "description": "Erhalten Sie Hilfe, wenn Sie sie brauchen, mit unserem dedizierten Kundensupport-Team."
} }
}, },
"pricing": { "pricing": {
"title": "Wählen Sie Ihren Plan", "title": "Wählen Sie Ihren Plan",
"subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", "subtitle": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse",
"choose_plan": "Wählen Sie Ihren Plan", "choose_plan": "Wählen Sie Ihren Plan",
"select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse", "select_plan": "Wählen Sie den perfekten Plan für Ihre QR-Code-Bedürfnisse",
"current_plan": "Aktueller Plan", "current_plan": "Aktueller Plan",
"upgrade_to": "Upgrade auf", "upgrade_to": "Upgrade auf",
"downgrade_to_free": "Zu Kostenlos zurückstufen", "downgrade_to_free": "Zu Kostenlos zurückstufen",
"most_popular": "Beliebteste", "most_popular": "Beliebteste",
"all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.", "all_plans_note": "Alle Pläne beinhalten unbegrenzte statische QR-Codes und Basis-Anpassung.",
"free": { "free": {
"title": "Kostenlos", "title": "Kostenlos",
"name": "Free", "name": "Free",
"price": "€0", "price": "€0",
"period": "für immer", "period": "für immer",
"features": [ "features": [
"3 aktive dynamische QR-Codes (8 Typen verfügbar)", "3 aktive dynamische QR-Codes (8 Typen verfügbar)",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Basis-Scan-Tracking", "Basis-Scan-Tracking",
"Standard QR-Design-Vorlagen" "Standard QR-Design-Vorlagen"
] ]
}, },
"pro": { "pro": {
"title": "Pro", "title": "Pro",
"name": "Pro", "name": "Pro",
"price": "€9", "price": "€9",
"period": "pro Monat", "period": "pro Monat",
"badge": "Beliebteste", "badge": "Beliebteste",
"features": [ "features": [
"50 dynamische QR-Codes", "50 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Erweiterte Analytik (Scans, Geräte, Standorte)", "Erweiterte Analytik (Scans, Geräte, Standorte)",
"Individuelles Branding (Farben)", "Individuelles Branding (Farben)",
"Download als SVG/PNG" "Download als SVG/PNG"
] ]
}, },
"business": { "business": {
"title": "Business", "title": "Business",
"name": "Business", "name": "Business",
"price": "€29", "price": "€29",
"period": "pro Monat", "period": "pro Monat",
"features": [ "features": [
"500 dynamische QR-Codes", "500 dynamische QR-Codes",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Alles aus Pro", "Alles aus Pro",
"Massen-QR-Erstellung (bis zu 1.000)", "Massen-QR-Erstellung (bis zu 1.000)",
"Prioritäts-E-Mail-Support", "Prioritäts-E-Mail-Support",
"Erweiterte Tracking & Insights" "Erweiterte Tracking & Insights"
] ]
}, },
"enterprise": { "enterprise": {
"title": "Enterprise", "title": "Enterprise",
"name": "Enterprise", "name": "Enterprise",
"price": "Individuell", "price": "Individuell",
"period": "", "period": "",
"features": [ "features": [
"∞ dynamische QR-Codes", "∞ dynamische QR-Codes",
"Unbegrenzte statische QR-Codes", "Unbegrenzte statische QR-Codes",
"Alles aus Business", "Alles aus Business",
"Eigener Account Manager" "Eigener Account Manager"
], ],
"contact": "Kontakt aufnehmen" "contact": "Kontakt aufnehmen"
} }
}, },
"faq": { "faq": {
"title": "Häufig gestellte Fragen", "title": "Häufig gestellte Fragen",
"questions": { "questions": {
"account": { "account": {
"question": "Benötige ich ein Konto, um QR-Codes zu erstellen?", "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." "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": { "static_vs_dynamic": {
"question": "Was ist der Unterschied zwischen statischen und dynamischen QR-Codes?", "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." "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": { "forever": {
"question": "Funktionieren meine QR-Codes für immer?", "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." "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": { "file_type": {
"question": "Welchen Dateityp sollte ich zum Drucken verwenden?", "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." "answer": "Für Druckmaterialien empfehlen wir das SVG-Format für Skalierbarkeit oder hochauflösendes PNG (300+ DPI) für beste Qualität."
}, },
"password": { "password": {
"question": "Kann ich einen QR-Code mit einem Passwort schützen?", "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." "answer": "Ja, Pro- und Business-Pläne beinhalten Passwortschutz und Zugriffskontrollfunktionen für Ihre QR-Codes."
}, },
"analytics": { "analytics": {
"question": "Wie funktioniert die Analytik?", "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." "answer": "Wir verfolgen Scans, Standorte, Geräte und Referrer unter Beachtung der Privatsphäre der Nutzer. Keine persönlichen Daten werden gespeichert."
}, },
"privacy": { "privacy": {
"question": "Verfolgen Sie persönliche Daten?", "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." "answer": "Wir respektieren die Privatsphäre und sammeln nur anonyme Nutzungsdaten. IP-Adressen werden gehasht und wir respektieren Do-Not-Track-Header."
}, },
"bulk": { "bulk": {
"question": "Kann ich Codes in großen Mengen mit meinen eigenen Daten erstellen?", "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." "answer": "Ja, Sie können CSV- oder Excel-Dateien hochladen, um mehrere QR-Codes auf einmal mit individueller Datenzuordnung zu erstellen."
}, },
"no_account": { "no_account": {
"question": "Kann ich QR-Codes auch ohne Anmeldung erstellen?", "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." "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": { "restaurant_menu": {
"question": "Kann ich eine digitale Speisekarte per QR-Code erstellen?", "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." "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": { "scan_rate": {
"question": "Was beeinflusst die Scan-Rate meines QR-Codes?", "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." "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": { "branding": {
"question": "Kann ich meinen QR-Code mit meinem Logo branden?", "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." "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": { "data_security": {
"question": "Sind meine QR-Code-Daten in Deutschland sicher?", "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." "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": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance", "subtitle": "Verwalten Sie Ihre QR-Codes und verfolgen Sie Ihre Performance",
"stats": { "stats": {
"total_scans": "Gesamte Scans", "total_scans": "Gesamte Scans",
"active_codes": "Aktive QR-Codes", "active_codes": "Aktive QR-Codes",
"conversion_rate": "Konversionsrate" "conversion_rate": "Konversionsrate"
}, },
"recent_codes": "Aktuelle QR-Codes", "recent_codes": "Aktuelle QR-Codes",
"blog_resources": "Blog & Ressourcen", "blog_resources": "Blog & Ressourcen",
"menu": { "menu": {
"edit": "Bearbeiten", "edit": "Bearbeiten",
"duplicate": "Duplizieren", "duplicate": "Duplizieren",
"pause": "Pausieren", "pause": "Pausieren",
"delete": "Löschen" "delete": "Löschen"
} }
}, },
"create": { "create": {
"title": "QR-Code erstellen", "title": "QR-Code erstellen",
"subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding", "subtitle": "Generieren Sie dynamische und statische QR-Codes mit individuellem Branding",
"content": "Inhalt", "content": "Inhalt",
"type": "QR-Code-Typ", "type": "QR-Code-Typ",
"style": "Stil & Branding", "style": "Stil & Branding",
"preview": "Live-Vorschau", "preview": "Live-Vorschau",
"title_label": "Titel", "title_label": "Titel",
"title_placeholder": "Mein QR-Code", "title_placeholder": "Mein QR-Code",
"content_type": "Inhaltstyp", "content_type": "Inhaltstyp",
"url_label": "URL", "url_label": "URL",
"url_placeholder": "https://beispiel.de", "url_placeholder": "https://beispiel.de",
"tags_label": "Tags (durch Komma getrennt)", "tags_label": "Tags (durch Komma getrennt)",
"tags_placeholder": "marketing, kampagne, 2025", "tags_placeholder": "marketing, kampagne, 2025",
"qr_code_type": "QR-Code-Typ", "qr_code_type": "QR-Code-Typ",
"dynamic": "Dynamisch", "dynamic": "Dynamisch",
"static": "Statisch", "static": "Statisch",
"recommended": "Empfohlen", "recommended": "Empfohlen",
"dynamic_description": "Dynamisch: Scans verfolgen, URL später bearbeiten, Analytik ansehen. QR enthält Tracking-Link.", "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.", "static_description": "Statisch: Direkt zum Inhalt, kein Tracking, nicht bearbeitbar. QR enthält tatsächlichen Inhalt.",
"foreground_color": "Vordergrundfarbe", "foreground_color": "Vordergrundfarbe",
"background_color": "Hintergrundfarbe", "background_color": "Hintergrundfarbe",
"corner_style": "Eckenstil", "corner_style": "Eckenstil",
"size": "Größe", "size": "Größe",
"good_contrast": "Guter Kontrast", "good_contrast": "Guter Kontrast",
"contrast_ratio": "Kontrastverhältnis", "contrast_ratio": "Kontrastverhältnis",
"download_svg": "SVG herunterladen", "download_svg": "SVG herunterladen",
"download_png": "PNG herunterladen", "download_png": "PNG herunterladen",
"save_qr_code": "QR-Code speichern" "save_qr_code": "QR-Code speichern"
}, },
"analytics": { "analytics": {
"title": "Analytik", "title": "Analytik",
"subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes", "subtitle": "Verfolgen und analysieren Sie die Performance Ihrer QR-Codes",
"export_report": "Bericht exportieren", "export_report": "Bericht exportieren",
"from_last_period": "vom letzten Zeitraum", "from_last_period": "vom letzten Zeitraum",
"no_mobile_scans": "Keine mobilen Scans", "no_mobile_scans": "Keine mobilen Scans",
"of_total": "der Gesamtmenge", "of_total": "der Gesamtmenge",
"ranges": { "ranges": {
"7d": "7 Tage", "7d": "7 Tage",
"30d": "30 Tage", "30d": "30 Tage",
"90d": "90 Tage" "90d": "90 Tage"
}, },
"kpis": { "kpis": {
"total_scans": "Gesamte Scans", "total_scans": "Gesamte Scans",
"avg_scans": "Ø Scans/QR", "avg_scans": "Ø Scans/QR",
"mobile_usage": "Mobile Nutzung", "mobile_usage": "Mobile Nutzung",
"top_country": "Top Land" "top_country": "Top Land"
}, },
"charts": { "charts": {
"scans_over_time": "Scans über Zeit", "scans_over_time": "Scans über Zeit",
"device_types": "Gerätetypen", "device_types": "Gerätetypen",
"top_countries": "Top Länder" "top_countries": "Top Länder"
}, },
"table": { "table": {
"qr_code": "QR-Code", "qr_code": "QR-Code",
"type": "Typ", "type": "Typ",
"total_scans": "Gesamte Scans", "total_scans": "Gesamte Scans",
"unique_scans": "Einzigartige Scans", "unique_scans": "Einzigartige Scans",
"conversion": "Konversion", "conversion": "Konversion",
"trend": "Trend", "trend": "Trend",
"scans": "Scans", "scans": "Scans",
"percentage": "Prozent", "percentage": "Prozent",
"country": "Land", "country": "Land",
"performance": "Performance", "performance": "Performance",
"created": "Erstellt", "created": "Erstellt",
"status": "Status" "status": "Status"
}, },
"performance_title": "QR-Code-Performance" "performance_title": "QR-Code-Performance"
}, },
"bulk": { "bulk": {
"title": "Massen-Erstellung", "title": "Massen-Erstellung",
"subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien", "subtitle": "Erstellen Sie mehrere QR-Codes gleichzeitig aus CSV- oder Excel-Dateien",
"template_warning_title": "Bitte folgen Sie dem Vorlagenformat", "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.", "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_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.", "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", "download_template": "Vorlage herunterladen",
"no_file_selected": "Keine ausgewählt", "no_file_selected": "Keine ausgewählt",
"simple_format": "Einfaches Format", "simple_format": "Einfaches Format",
"just_title_url": "Nur Titel & URL", "just_title_url": "Nur Titel & URL",
"static_qr_codes": "Statische QR-Codes", "static_qr_codes": "Statische QR-Codes",
"no_tracking": "Kein Tracking enthalten", "no_tracking": "Kein Tracking enthalten",
"instant_download": "Sofortiger Download", "instant_download": "Sofortiger Download",
"get_zip": "ZIP mit allen SVGs erhalten", "get_zip": "ZIP mit allen SVGs erhalten",
"max_rows": "max 1.000 Zeilen", "max_rows": "max 1.000 Zeilen",
"steps": { "steps": {
"upload": "Datei hochladen", "upload": "Datei hochladen",
"preview": "Vorschau & Zuordnung", "preview": "Vorschau & Zuordnung",
"download": "Herunterladen" "download": "Herunterladen"
}, },
"drag_drop": "Datei hier hinziehen", "drag_drop": "Datei hier hinziehen",
"or_click": "oder klicken zum Durchsuchen", "or_click": "oder klicken zum Durchsuchen",
"supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)" "supported_formats": "Unterstützt CSV, XLS, XLSX (max 1.000 Zeilen)"
}, },
"integrations": { "integrations": {
"title": "Integrationen", "title": "Integrationen",
"metrics": { "metrics": {
"total_codes": "QR-Codes Gesamt", "total_codes": "QR-Codes Gesamt",
"active_integrations": "Aktive Integrationen", "active_integrations": "Aktive Integrationen",
"sync_status": "Sync-Status", "sync_status": "Sync-Status",
"available_services": "Verfügbare Services" "available_services": "Verfügbare Services"
}, },
"zapier": { "zapier": {
"title": "Zapier", "title": "Zapier",
"description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps", "description": "Automatisieren Sie QR-Code-Erstellung mit 5000+ Apps",
"features": [ "features": [
"Trigger bei neuen QR-Codes", "Trigger bei neuen QR-Codes",
"Codes aus anderen Apps erstellen", "Codes aus anderen Apps erstellen",
"Scan-Daten synchronisieren" "Scan-Daten synchronisieren"
] ]
}, },
"airtable": { "airtable": {
"title": "Airtable", "title": "Airtable",
"description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen", "description": "Synchronisieren Sie QR-Codes mit Ihren Airtable-Basen",
"features": [ "features": [
"Bidirektionale Synchronisation", "Bidirektionale Synchronisation",
"Individuelle Feldzuordnung", "Individuelle Feldzuordnung",
"Echtzeit-Updates" "Echtzeit-Updates"
] ]
}, },
"sheets": { "sheets": {
"title": "Google Sheets", "title": "Google Sheets",
"description": "Exportieren Sie Daten automatisch zu Google Sheets", "description": "Exportieren Sie Daten automatisch zu Google Sheets",
"features": [ "features": [
"Automatisierte Exporte", "Automatisierte Exporte",
"Individuelle Vorlagen", "Individuelle Vorlagen",
"Geplante Updates" "Geplante Updates"
] ]
}, },
"activate": "Aktivieren & Konfigurieren" "activate": "Aktivieren & Konfigurieren"
}, },
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
"subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen", "subtitle": "Verwalten Sie Ihre Kontoeinstellungen und Präferenzen",
"tabs": { "tabs": {
"profile": "Profil", "profile": "Profil",
"billing": "Abrechnung", "billing": "Abrechnung",
"team": "Team & Rollen", "team": "Team & Rollen",
"api": "API-Schlüssel", "api": "API-Schlüssel",
"workspace": "Arbeitsbereich" "workspace": "Arbeitsbereich"
} }
}, },
"common": { "common": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"delete": "Löschen", "delete": "Löschen",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"create": "Erstellen", "create": "Erstellen",
"loading": "Lädt...", "loading": "Lädt...",
"error": "Ein Fehler ist aufgetreten", "error": "Ein Fehler ist aufgetreten",
"success": "Erfolgreich!" "success": "Erfolgreich!"
}, },
"footer": { "footer": {
"product": "Produkt", "product": "Produkt",
"features": "Funktionen", "features": "Funktionen",
"pricing": "Preise", "pricing": "Preise",
"faq": "FAQ", "faq": "FAQ",
"blog": "Blog", "blog": "Blog",
"resources": "Ressourcen", "resources": "Ressourcen",
"full_pricing": "Alle Preise", "full_pricing": "Alle Preise",
"all_questions": "Alle Fragen", "all_questions": "Alle Fragen",
"all_articles": "Alle Artikel", "all_articles": "Alle Artikel",
"learn": "Lernen", "learn": "Lernen",
"get_started": "Loslegen", "get_started": "Loslegen",
"legal": "Rechtliches", "legal": "Rechtliches",
"industries": "Branchen", "industries": "Branchen",
"privacy_policy": "Datenschutzerklärung", "privacy_policy": "Datenschutzerklärung",
"terms_of_service": "Nutzungsbedingungen", "terms_of_service": "Nutzungsbedingungen",
"tagline": "Erstellen Sie benutzerdefinierte QR-Codes in Sekunden mit erweitertem Tracking und Analysen.", "tagline": "Erstellen Sie benutzerdefinierte QR-Codes in Sekunden mit erweitertem Tracking und Analysen.",
"newsletter": "Newsletter-Anmeldung", "newsletter": "Newsletter-Anmeldung",
"rights_reserved": "QR Master. Alle Rechte vorbehalten." "rights_reserved": "QR Master. Alle Rechte vorbehalten."
} }
} }

View File

@@ -1,412 +1,412 @@
{ {
"nav": { "nav": {
"features": "Features", "features": "Features",
"pricing": "Pricing", "pricing": "Pricing",
"faq": "FAQ", "faq": "FAQ",
"blog": "Blog", "blog": "Blog",
"login": "Login", "login": "Login",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"about": "About", "about": "About",
"contact": "Contact", "contact": "Contact",
"signup": "Sign Up", "signup": "Sign Up",
"learn": "Learn", "learn": "Learn",
"create_qr": "Create QR", "create_qr": "Create QR",
"bulk_creation": "Bulk Creation", "bulk_creation": "Bulk Creation",
"analytics": "Analytics", "analytics": "Analytics",
"settings": "Settings", "settings": "Settings",
"cta": "Get Started Free", "cta": "Get Started Free",
"tools": "Free Tools", "tools": "Free Tools",
"all_free": "All generators are 100% free", "all_free": "All generators are 100% free",
"resources": "Resources", "resources": "Resources",
"all_industries": "Industries" "all_industries": "Industries"
}, },
"hero": { "hero": {
"badge": "QR Master Free QR Code Generator", "badge": "QR Master Free QR Code Generator",
"title": "QR Master: Free Dynamic QR Code Generator with Tracking", "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.", "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": [ "features": [
"No credit card required to start", "No credit card required to start",
"Create QR codes free forever", "Create QR codes free forever",
"Advanced tracking and analytics", "Advanced tracking and analytics",
"Custom colors and styles" "Custom colors and styles"
], ],
"cta_primary": "Make a QR Code Free", "cta_primary": "Make a QR Code Free",
"cta_secondary": "View Pricing", "cta_secondary": "View Pricing",
"engagement_badge": "Free Forever" "engagement_badge": "Free Forever"
}, },
"trust": { "trust": {
"users": "Happy Users", "users": "Happy Users",
"codes": "Active QR Codes", "codes": "Active QR Codes",
"scans": "Total Scans", "scans": "Total Scans",
"countries": "Countries" "countries": "Countries"
}, },
"industries": { "industries": {
"restaurant": "Restaurant Chain", "restaurant": "Restaurant Chain",
"tech": "Tech Startup", "tech": "Tech Startup",
"realestate": "Real Estate", "realestate": "Real Estate",
"events": "Event Agency", "events": "Event Agency",
"retail": "Retail Store", "retail": "Retail Store",
"healthcare": "Healthcare" "healthcare": "Healthcare"
}, },
"templates": { "templates": {
"title": "Start with a Template", "title": "Start with a Template",
"restaurant": "Restaurant Menu", "restaurant": "Restaurant Menu",
"business": "Business Card", "business": "Business Card",
"vcard": "Contact Card", "vcard": "Contact Card",
"event": "Event Ticket", "event": "Event Ticket",
"use_template": "Use template →" "use_template": "Use template →"
}, },
"generator": { "generator": {
"title": "Instant QR Code Generator", "title": "Instant QR Code Generator",
"url_placeholder": "Enter your URL here...", "url_placeholder": "Enter your URL here...",
"foreground": "Foreground", "foreground": "Foreground",
"background": "Background", "background": "Background",
"corners": "Corners", "corners": "Corners",
"size": "Size", "size": "Size",
"contrast_good": "Good contrast", "contrast_good": "Good contrast",
"download_svg": "Download SVG", "download_svg": "Download SVG",
"download_png": "Download PNG", "download_png": "Download PNG",
"save_track": "Save & Track", "save_track": "Save & Track",
"live_preview": "Live Preview", "live_preview": "Live Preview",
"demo_note": "This is a demo QR code" "demo_note": "This is a demo QR code"
}, },
"static_vs_dynamic": { "static_vs_dynamic": {
"title": "Why Dynamic QR Codes Save You Money", "title": "Why Dynamic QR Codes Save You Money",
"description": "Stop re-printing materials. Switch destinations instantly and track every scan.", "description": "Stop re-printing materials. Switch destinations instantly and track every scan.",
"static": { "static": {
"title": "Static QR Codes", "title": "Static QR Codes",
"subtitle": "Always Free", "subtitle": "Always Free",
"description": "Perfect for permanent content that never changes", "description": "Perfect for permanent content that never changes",
"features": [ "features": [
"Content cannot be edited", "Content cannot be edited",
"No scan tracking", "No scan tracking",
"Works forever", "Works forever",
"No account required" "No account required"
] ]
}, },
"dynamic": { "dynamic": {
"title": "Dynamic QR Codes", "title": "Dynamic QR Codes",
"subtitle": "Recommended", "subtitle": "Recommended",
"description": "Full control with tracking and editing capabilities", "description": "Full control with tracking and editing capabilities",
"features": [ "features": [
"Edit content anytime", "Edit content anytime",
"Advanced analytics", "Advanced analytics",
"Custom branding", "Custom branding",
"Bulk operations" "Bulk operations"
] ]
} }
}, },
"features": { "features": {
"title": "Everything you need to create professional QR codes", "title": "Everything you need to create professional QR codes",
"analytics": { "analytics": {
"title": "Advanced Analytics", "title": "Advanced Analytics",
"description": "Track scans, locations, devices, and user behavior with detailed insights." "description": "Track scans, locations, devices, and user behavior with detailed insights."
}, },
"customization": { "customization": {
"title": "Full Customization", "title": "Full Customization",
"description": "Brand your QR codes with custom colors and styling options." "description": "Brand your QR codes with custom colors and styling options."
}, },
"unlimited": { "unlimited": {
"title": "Unlimited Static QR Codes", "title": "Unlimited Static QR Codes",
"description": "Create as many static QR codes as you need. Free forever, no limits." "description": "Create as many static QR codes as you need. Free forever, no limits."
}, },
"bulk": { "bulk": {
"title": "Bulk Operations", "title": "Bulk Operations",
"description": "Create hundreds of QR codes at once with CSV import and batch processing." "description": "Create hundreds of QR codes at once with CSV import and batch processing."
}, },
"integrations": { "integrations": {
"title": "Integrations", "title": "Integrations",
"description": "Connect with Zapier, Airtable, Google Sheets, and more popular tools." "description": "Connect with Zapier, Airtable, Google Sheets, and more popular tools."
}, },
"api": { "api": {
"title": "Developer API", "title": "Developer API",
"description": "Integrate QR code generation into your applications with our REST API." "description": "Integrate QR code generation into your applications with our REST API."
}, },
"support": { "support": {
"title": "24/7 Support", "title": "24/7 Support",
"description": "Get help when you need it with our dedicated customer support team." "description": "Get help when you need it with our dedicated customer support team."
} }
}, },
"pricing": { "pricing": {
"title": "Choose Your Plan", "title": "Choose Your Plan",
"subtitle": "Select the perfect plan for your QR code needs", "subtitle": "Select the perfect plan for your QR code needs",
"choose_plan": "Choose Your Plan", "choose_plan": "Choose Your Plan",
"select_plan": "Select the perfect plan for your QR code needs", "select_plan": "Select the perfect plan for your QR code needs",
"current_plan": "Current Plan", "current_plan": "Current Plan",
"upgrade_to": "Upgrade to", "upgrade_to": "Upgrade to",
"downgrade_to_free": "Downgrade to Free", "downgrade_to_free": "Downgrade to Free",
"most_popular": "Most Popular", "most_popular": "Most Popular",
"all_plans_note": "All plans include unlimited static QR codes and basic customization.", "all_plans_note": "All plans include unlimited static QR codes and basic customization.",
"free": { "free": {
"title": "Free", "title": "Free",
"name": "Free", "name": "Free",
"price": "€0", "price": "€0",
"period": "forever", "period": "forever",
"features": [ "features": [
"3 active dynamic QR codes (8 types available)", "3 active dynamic QR codes (8 types available)",
"Unlimited static QR codes", "Unlimited static QR codes",
"Basic scan tracking", "Basic scan tracking",
"Standard QR design templates", "Standard QR design templates",
"Download as SVG/PNG" "Download as SVG/PNG"
] ]
}, },
"pro": { "pro": {
"title": "Pro", "title": "Pro",
"name": "Pro", "name": "Pro",
"price": "€9", "price": "€9",
"period": "per month", "period": "per month",
"badge": "Most Popular", "badge": "Most Popular",
"features": [ "features": [
"50 dynamic QR codes", "50 dynamic QR codes",
"Unlimited static QR codes", "Unlimited static QR codes",
"Advanced analytics (scans, devices, locations)", "Advanced analytics (scans, devices, locations)",
"Custom branding (colors & logos)" "Custom branding (colors & logos)"
] ]
}, },
"business": { "business": {
"title": "Business", "title": "Business",
"name": "Business", "name": "Business",
"price": "€29", "price": "€29",
"period": "per month", "period": "per month",
"features": [ "features": [
"500 dynamic QR codes", "500 dynamic QR codes",
"Unlimited static QR codes", "Unlimited static QR codes",
"Everything from Pro", "Everything from Pro",
"Bulk QR Creation (up to 1,000)", "Bulk QR Creation (up to 1,000)",
"Priority email support", "Priority email support",
"Advanced tracking & insights" "Advanced tracking & insights"
] ]
}, },
"enterprise": { "enterprise": {
"title": "Enterprise", "title": "Enterprise",
"name": "Enterprise", "name": "Enterprise",
"price": "Custom", "price": "Custom",
"period": "", "period": "",
"features": [ "features": [
"∞ dynamic QR codes", "∞ dynamic QR codes",
"Unlimited static QR codes", "Unlimited static QR codes",
"Everything from Business", "Everything from Business",
"Dedicated Account Manager" "Dedicated Account Manager"
], ],
"contact": "Contact Us" "contact": "Contact Us"
} }
}, },
"faq": { "faq": {
"title": "Frequently Asked Questions", "title": "Frequently Asked Questions",
"questions": { "questions": {
"account": { "account": {
"question": "Do I need an account to create QR codes?", "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." "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": { "static_vs_dynamic": {
"question": "What's the difference between static and dynamic QR codes?", "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." "answer": "Static QR codes contain fixed content that cannot be changed. Dynamic QR codes can be edited anytime and provide detailed analytics."
}, },
"forever": { "forever": {
"question": "Will my QR codes work 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." "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": { "file_type": {
"question": "What file type should I use for printing?", "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." "answer": "For print materials, we recommend SVG format for scalability or high-resolution PNG (300+ DPI) for best quality."
}, },
"password": { "password": {
"question": "Can I password-protect a QR code?", "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." "answer": "Yes, Pro and Business plans include password protection and access control features for your QR codes."
}, },
"analytics": { "analytics": {
"question": "How do analytics work?", "question": "How do analytics work?",
"answer": "We track scans, locations, devices, and referrers while respecting user privacy. No personal data is stored." "answer": "We track scans, locations, devices, and referrers while respecting user privacy. No personal data is stored."
}, },
"privacy": { "privacy": {
"question": "Do you track personal data?", "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." "answer": "We respect privacy and only collect anonymous usage data. IP addresses are hashed and we honor Do Not Track headers."
}, },
"bulk": { "bulk": {
"question": "Can I bulk-create codes with my own data?", "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." "answer": "Yes, you can upload CSV or Excel files to create multiple QR codes at once with custom data mapping."
} }
} }
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"subtitle": "Manage your QR codes and track performance", "subtitle": "Manage your QR codes and track performance",
"stats": { "stats": {
"total_scans": "Total Scans", "total_scans": "Total Scans",
"active_codes": "Active QR Codes", "active_codes": "Active QR Codes",
"conversion_rate": "Conversion Rate" "conversion_rate": "Conversion Rate"
}, },
"recent_codes": "Recent QR Codes", "recent_codes": "Recent QR Codes",
"blog_resources": "Blog & Resources", "blog_resources": "Blog & Resources",
"menu": { "menu": {
"edit": "Edit", "edit": "Edit",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"pause": "Pause", "pause": "Pause",
"delete": "Delete" "delete": "Delete"
} }
}, },
"create": { "create": {
"title": "Create QR Code", "title": "Create QR Code",
"subtitle": "Generate dynamic and static QR codes with custom branding", "subtitle": "Generate dynamic and static QR codes with custom branding",
"content": "Content", "content": "Content",
"type": "QR Code Type", "type": "QR Code Type",
"style": "Style & Branding", "style": "Style & Branding",
"preview": "Live Preview", "preview": "Live Preview",
"title_label": "Title", "title_label": "Title",
"title_placeholder": "My QR Code", "title_placeholder": "My QR Code",
"content_type": "Content Type", "content_type": "Content Type",
"url_label": "URL", "url_label": "URL",
"url_placeholder": "https://example.com", "url_placeholder": "https://example.com",
"tags_label": "Tags (comma-separated)", "tags_label": "Tags (comma-separated)",
"tags_placeholder": "marketing, campaign, 2025", "tags_placeholder": "marketing, campaign, 2025",
"qr_code_type": "QR Code Type", "qr_code_type": "QR Code Type",
"dynamic": "Dynamic", "dynamic": "Dynamic",
"static": "Static", "static": "Static",
"recommended": "Recommended", "recommended": "Recommended",
"dynamic_description": "Dynamic: Track scans, edit URL later, view analytics. QR contains tracking link.", "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.", "static_description": "Static: Direct to content, no tracking, cannot edit. QR contains actual content.",
"foreground_color": "Foreground Color", "foreground_color": "Foreground Color",
"background_color": "Background Color", "background_color": "Background Color",
"corner_style": "Corner Style", "corner_style": "Corner Style",
"size": "Size", "size": "Size",
"good_contrast": "Good contrast", "good_contrast": "Good contrast",
"contrast_ratio": "Contrast ratio", "contrast_ratio": "Contrast ratio",
"download_svg": "Download SVG", "download_svg": "Download SVG",
"download_png": "Download PNG", "download_png": "Download PNG",
"save_qr_code": "Save QR Code" "save_qr_code": "Save QR Code"
}, },
"analytics": { "analytics": {
"title": "Analytics", "title": "Analytics",
"subtitle": "Track and analyze your QR code performance", "subtitle": "Track and analyze your QR code performance",
"export_report": "Export Report", "export_report": "Export Report",
"from_last_period": "from last period", "from_last_period": "from last period",
"no_mobile_scans": "No mobile scans", "no_mobile_scans": "No mobile scans",
"of_total": "of total", "of_total": "of total",
"ranges": { "ranges": {
"7d": "7 Days", "7d": "7 Days",
"30d": "30 Days", "30d": "30 Days",
"90d": "90 Days" "90d": "90 Days"
}, },
"kpis": { "kpis": {
"total_scans": "Total Scans", "total_scans": "Total Scans",
"avg_scans": "Avg Scans/QR", "avg_scans": "Avg Scans/QR",
"mobile_usage": "Mobile Usage", "mobile_usage": "Mobile Usage",
"top_country": "Top Country" "top_country": "Top Country"
}, },
"charts": { "charts": {
"scans_over_time": "Scans Over Time", "scans_over_time": "Scans Over Time",
"device_types": "Device Types", "device_types": "Device Types",
"top_countries": "Top Countries" "top_countries": "Top Countries"
}, },
"table": { "table": {
"qr_code": "QR Code", "qr_code": "QR Code",
"type": "Type", "type": "Type",
"total_scans": "Total Scans", "total_scans": "Total Scans",
"unique_scans": "Unique Scans", "unique_scans": "Unique Scans",
"conversion": "Conversion", "conversion": "Conversion",
"trend": "Trend", "trend": "Trend",
"scans": "Scans", "scans": "Scans",
"percentage": "Percentage", "percentage": "Percentage",
"country": "Country", "country": "Country",
"performance": "Performance", "performance": "Performance",
"created": "Created", "created": "Created",
"status": "Status" "status": "Status"
}, },
"performance_title": "QR Code Performance" "performance_title": "QR Code Performance"
}, },
"bulk": { "bulk": {
"title": "Bulk Creation", "title": "Bulk Creation",
"subtitle": "Create multiple QR codes at once from CSV or Excel files", "subtitle": "Create multiple QR codes at once from CSV or Excel files",
"template_warning_title": "Please Follow the Template Format", "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).", "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_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.", "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", "download_template": "Download Template",
"no_file_selected": "No file selected", "no_file_selected": "No file selected",
"simple_format": "Simple Format", "simple_format": "Simple Format",
"just_title_url": "Just title & URL", "just_title_url": "Just title & URL",
"static_qr_codes": "Static QR Codes", "static_qr_codes": "Static QR Codes",
"no_tracking": "No tracking included", "no_tracking": "No tracking included",
"instant_download": "Instant Download", "instant_download": "Instant Download",
"get_zip": "Get ZIP with all SVGs", "get_zip": "Get ZIP with all SVGs",
"max_rows": "max 1,000 rows", "max_rows": "max 1,000 rows",
"steps": { "steps": {
"upload": "Upload File", "upload": "Upload File",
"preview": "Preview & Map", "preview": "Preview & Map",
"download": "Download" "download": "Download"
}, },
"drag_drop": "Drag & drop your file here", "drag_drop": "Drag & drop your file here",
"or_click": "or click to browse", "or_click": "or click to browse",
"supported_formats": "Supports CSV, XLS, XLSX (max 1,000 rows)" "supported_formats": "Supports CSV, XLS, XLSX (max 1,000 rows)"
}, },
"integrations": { "integrations": {
"title": "Integrations", "title": "Integrations",
"metrics": { "metrics": {
"total_codes": "QR Codes Total", "total_codes": "QR Codes Total",
"active_integrations": "Active Integrations", "active_integrations": "Active Integrations",
"sync_status": "Sync Status", "sync_status": "Sync Status",
"available_services": "Available Services" "available_services": "Available Services"
}, },
"zapier": { "zapier": {
"title": "Zapier", "title": "Zapier",
"description": "Automate QR code creation with 5000+ apps", "description": "Automate QR code creation with 5000+ apps",
"features": [ "features": [
"Trigger on new QR codes", "Trigger on new QR codes",
"Create codes from other apps", "Create codes from other apps",
"Sync scan data" "Sync scan data"
] ]
}, },
"airtable": { "airtable": {
"title": "Airtable", "title": "Airtable",
"description": "Sync QR codes with your Airtable bases", "description": "Sync QR codes with your Airtable bases",
"features": ["Two-way sync", "Custom field mapping", "Real-time updates"] "features": ["Two-way sync", "Custom field mapping", "Real-time updates"]
}, },
"sheets": { "sheets": {
"title": "Google Sheets", "title": "Google Sheets",
"description": "Export data to Google Sheets automatically", "description": "Export data to Google Sheets automatically",
"features": ["Automated exports", "Custom templates", "Scheduled updates"] "features": ["Automated exports", "Custom templates", "Scheduled updates"]
}, },
"activate": "Activate & Configure" "activate": "Activate & Configure"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"subtitle": "Manage your account settings and preferences", "subtitle": "Manage your account settings and preferences",
"tabs": { "tabs": {
"profile": "Profile", "profile": "Profile",
"billing": "Billing", "billing": "Billing",
"team": "Team & Roles", "team": "Team & Roles",
"api": "API Keys", "api": "API Keys",
"workspace": "Workspace" "workspace": "Workspace"
} }
}, },
"common": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"create": "Create", "create": "Create",
"loading": "Loading...", "loading": "Loading...",
"error": "An error occurred", "error": "An error occurred",
"success": "Success!" "success": "Success!"
}, },
"footer": { "footer": {
"product": "Product", "product": "Product",
"features": "Features", "features": "Features",
"pricing": "Pricing", "pricing": "Pricing",
"faq": "FAQ", "faq": "FAQ",
"blog": "Blog", "blog": "Blog",
"resources": "Resources", "resources": "Resources",
"full_pricing": "Full Pricing", "full_pricing": "Full Pricing",
"all_questions": "All Questions", "all_questions": "All Questions",
"all_articles": "All Articles", "all_articles": "All Articles",
"learn": "Learn", "learn": "Learn",
"get_started": "Get Started", "get_started": "Get Started",
"legal": "Legal", "legal": "Legal",
"industries": "Industries", "industries": "Industries",
"privacy_policy": "Privacy Policy", "privacy_policy": "Privacy Policy",
"terms_of_service": "Terms of Service", "terms_of_service": "Terms of Service",
"tagline": "Create custom QR codes in seconds with advanced tracking and analytics.", "tagline": "Create custom QR codes in seconds with advanced tracking and analytics.",
"newsletter": "Newsletter signup", "newsletter": "Newsletter signup",
"rights_reserved": "QR Master. All rights reserved." "rights_reserved": "QR Master. All rights reserved."
} }
} }

59
src/lib/tiktok.ts Normal file
View File

@@ -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,
},
});
}