diff --git a/.env.example b/.env.example index 814754c..231e562 100644 --- a/.env.example +++ b/.env.example @@ -1,51 +1,19 @@ -DATABASE_URL="postgresql://s2yt:s2yt@localhost:5433/s2yt" +# Songs2VID OSS is always self-hosted and has no billing configuration. +DATABASE_URL="postgresql://songs2vid:songs2vid@localhost:5433/songs2vid" REDIS_URL="redis://localhost:6380" NEXTAUTH_URL="http://localhost:3000" -# Generate with: openssl rand -base64 32 NEXTAUTH_SECRET="replace-with-a-long-random-secret" -# Used to encrypt YouTube OAuth tokens at rest (falls back to NEXTAUTH_SECRET if unset) +# Optional; falls back to NEXTAUTH_SECRET. # TOKEN_ENCRYPTION_KEY="replace-with-another-long-random-secret" -# Server-side Google OAuth 2.0 credentials from Google Cloud Console + GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com" GOOGLE_CLIENT_SECRET="your-google-client-secret" UPLOAD_DIR="./uploads" -# Open-source / self-hosted: unlimited video quota, API, and Pro features -# S2VID_EDITION="selfhosted" -# Admin API key for approving/rejecting quota extension requests (Bearer token) -# ADMIN_API_KEY="your-secret-admin-key" -# Admin UI: /admin (footer badges) — unlock with the same Bearer key -# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY -# Admin badges: GET/POST /api/admin/badges, PATCH/DELETE /api/admin/badges/:id -# Public badges: GET /api/badges -# Pro users generate API keys in Dashboard → Settings (stored hashed; shown once) -# Optional: override bundled ffmpeg-static binary (leave unset to use npm package) -# FFMPEG_PATH="C:/path/to/ffmpeg.exe" -# Public Gitea issues URL for community support links + +NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com" NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues" NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID" NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/songs2vid" -# Docusaurus docs — local: npm run docs:dev → http://localhost:3001 -# NEXT_PUBLIC_DOCS_URL="http://localhost:3001" -# Production: NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com" -# Stripe billing (Pro €5/mo + Free 1–15 credit top-ups @ €0.25) -# STRIPE_SECRET_KEY="sk_test_..." -# Prefer a restricted key (rk_...) with Checkout + Customers + Billing Portal + Webhooks only -# STRIPE_WEBHOOK_SECRET="whsec_..." -# Optional Dashboard Price ID for Pro (otherwise inline price_data €5/mo is used) -# STRIPE_PRO_PRICE_ID="price_..." -# Collect VAT/GST via Stripe Tax AFTER adding Tax registrations in Dashboard: -# STRIPE_AUTOMATIC_TAX="true" -# Force local mock upgrades/grants without Stripe: BILLING_DEV_MOCK=true -# Disable auto-mock when Stripe key missing: BILLING_DEV_MOCK=false -# Point Stripe webhook to: POST /api/stripe/webhook -# Events (required): -# checkout.session.completed -# invoice.payment_succeeded -# invoice.payment_failed -# customer.subscription.updated -# customer.subscription.deleted -# Dashboard: enable Customer portal (Settings → Billing → Customer portal) -# Optional: Managed Payments (MoR) enable in Dashboard + set managed_payments on Checkout if you use it -# Dev helpers: POST /api/dev/grant-credits { "action": "set-pro" | "free-top-up" | "grant-credits" } -# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY -# See also /admin for footer badge management + +# Optional: override the bundled ffmpeg-static binary. +# FFMPEG_PATH="C:/path/to/ffmpeg.exe" diff --git a/Dockerfile b/Dockerfile index ed0c708..4f16193 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -# Hosted Songs2VID image (cloud plans + PAYG/Stripe). Do not bake selfhosted edition. +# Songs2VID OSS self-hosted image. FROM node:22-bookworm-slim AS deps WORKDIR /app diff --git a/README.md b/README.md index 5a6eac4..6a76c64 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,42 @@ -# Songs2VID +# Songs2VID OSS -Create YouTube videos from an image and audio files. +Payment-free, self-hosted software for creating videos from cover art and audio, then uploading them +to YouTube. All features are available in every deployment; there are no plans, credits, purchases, +subscriptions, or Stripe integration. -## Stack +## Quick start with Docker -- Next.js 15 (App Router, TypeScript, Tailwind) -- PostgreSQL + Prisma -- Redis + BullMQ -- NextAuth (Google OAuth 2.0 with YouTube scopes) -- FFmpeg for video encoding -- YouTube Data API v3 - -## Editions - -| Edition | How | Limits | -|---------|-----|--------| -| **Hosted** (default) | Leave `S2VID_EDITION` unset | Free / Pro quotas and API gates | -| **Self-hosted / OSS** | `S2VID_EDITION=selfhosted` | No video quota, no API rate caps; playlists + API unlocked | - -Docker Compose sets `S2VID_EDITION=selfhosted` automatically. - -## Quick start (Docker) - -1. Copy env and set Google OAuth + secrets: - -```bash -cp .env.example .env -``` - -2. Build and run the full stack (web, worker, Postgres, Redis): +1. Copy `.env.example` to `.env` and set `NEXTAUTH_SECRET`, `GOOGLE_CLIENT_ID`, and + `GOOGLE_CLIENT_SECRET`. +2. In Google Cloud Console, enable YouTube Data API v3 and add + `http://localhost:3000/api/auth/callback/google` as an OAuth redirect URI. +3. Start the stack: ```bash docker compose up -d --build ``` -Open [http://localhost:3000](http://localhost:3000). - -Add Google OAuth redirect URI: `http://localhost:3000/api/auth/callback/google` (or your public URL). +Open http://localhost:3000. ## Local development -1. Copy environment variables: - -```bash -cp .env.example .env -``` - -2. Start Postgres and Redis only: - ```bash docker compose -f docker-compose.dev.yml up -d -``` - -3. Install dependencies and push the schema: - -```bash npm install -npm run db:push -``` - -4. Configure Google OAuth in [Google Cloud Console](https://console.cloud.google.com/): - - Enable YouTube Data API v3 - - Create OAuth 2.0 credentials - - Add redirect URI: `http://localhost:3000/api/auth/callback/google` - - Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env` - -5. Optional for unlimited local use: `S2VID_EDITION=selfhosted` in `.env` - -6. Start the app, worker, and docs: - -```bash +npm run db:migrate npm run dev:all ``` -- App: http://localhost:3000 -- Docs: http://localhost:3001 +The web app runs on http://localhost:3000. `dev:all` starts both the Next.js app and BullMQ worker. -Or run them separately with `npm run dev`, `npm run worker`, and `npm run docs:dev`. +## Stack -## Authentication +- Next.js 15, TypeScript, Tailwind +- PostgreSQL and Prisma +- Redis and BullMQ +- NextAuth with Google OAuth +- FFmpeg +- YouTube Data API v3 -Users sign in with Google via OAuth 2.0. The app handles YouTube channel connection automatically end users never enter API keys for YouTube. - -## Self-hosted edition - -Set `S2VID_EDITION=selfhosted` (Docker Compose does this by default): - -- Unlimited video allowance -- API access and playlists -- Art-track layouts with blur backgrounds and fine-tuning (padding, gaps, text offsets) -- Custom watermarks (text/logo), curated or uploaded fonts - -Composition details: see `website/docs/video-editing.md` (or open Video editing in the docs site after `npm run docs:dev`). - -## Scripts - -- `npm run dev` - Next.js dev server -- `npm run worker` - Background job processor -- `npm run dev:all` - App (3000) + worker + docs (3001) together -- `npm run db:push` - Push Prisma schema to database -- `npm run build` - Production build -- `npm run docs:dev` - Documentation site only (Docusaurus on port 3001) - -## Documentation - -Docs live in `website/` (Docusaurus). API reference: - -```bash -npm run docs:dev -``` - -Open [http://localhost:3001/docs/api/overview](http://localhost:3001/docs/api/overview). - -The app “Full API docs” link and `/dashboard/api-docs` redirect use local docs when `NEXTAUTH_URL` is localhost (or set `NEXT_PUBLIC_DOCS_URL`). -## Source - -- Gitea: https://git.atakanozban.com/Songs2VID -- Docker Hub: https://hub.docker.com/r/atakanozban/songs2vid +Documentation, including the REST API reference, is at +[docs.songs2vid.com](https://docs.songs2vid.com). diff --git a/app/admin/page.tsx b/app/admin/page.tsx deleted file mode 100644 index cbea81a..0000000 --- a/app/admin/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AdminBadgePanel } from "@/components/AdminBadgePanel"; - -export const metadata = { - title: "Admin · Footer badges · Songs2VID", - robots: { index: false, follow: false }, -}; - -export default function AdminBadgesPage() { - return ( -
-
-
-

- Admin -

-

Footer badges

-

- Manage the infinite badge marquee on the public site footer. Only active badges are - shown. -

-
- -
-
- ); -} diff --git a/app/api/account/api-key/route.ts b/app/api/account/api-key/route.ts index 0cc8388..f5d3b03 100644 --- a/app/api/account/api-key/route.ts +++ b/app/api/account/api-key/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys"; -import { hasProFeatures } from "@/lib/edition"; import { getSessionUser } from "@/lib/session"; async function requireApiKeyAccess() { @@ -8,15 +7,6 @@ async function requireApiKeyAccess() { if (!user) { return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null }; } - if (!hasProFeatures(user.plan)) { - return { - error: NextResponse.json( - { error: "API keys are available on the Pro plan only" }, - { status: 403 }, - ), - user: null, - }; - } return { error: null, user }; } diff --git a/app/api/account/api-rate-limit-extension-request/route.ts b/app/api/account/api-rate-limit-extension-request/route.ts deleted file mode 100644 index 78c95ca..0000000 --- a/app/api/account/api-rate-limit-extension-request/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - createQuotaExtensionRequest, - EXTENSION_KIND, - getQuotaExtensionUsage, -} from "@/lib/quota-extensions"; -import { hasProFeatures } from "@/lib/edition"; -import { getSessionUser } from "@/lib/session"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!hasProFeatures(user.plan)) { - return NextResponse.json({ error: "Pro plan required" }, { status: 403 }); - } - - const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT); - return NextResponse.json(usage); -} - -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!hasProFeatures(user.plan)) { - return NextResponse.json({ error: "Pro plan required" }, { status: 403 }); - } - - let message = ""; - try { - const body = await req.json(); - if (typeof body.message === "string") message = body.message; - } catch { - // optional body - } - - try { - const result = await createQuotaExtensionRequest( - user.id, - message, - EXTENSION_KIND.API_RATE_LIMIT, - ); - return NextResponse.json(result); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit request"; - return NextResponse.json({ error: msg }, { status: 400 }); - } -} diff --git a/app/api/account/api-rate-limit/route.ts b/app/api/account/api-rate-limit/route.ts index a7f142d..4d15c21 100644 --- a/app/api/account/api-rate-limit/route.ts +++ b/app/api/account/api-rate-limit/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; -import { hasProFeatures } from "@/lib/edition"; import { getSessionUser } from "@/lib/session"; export async function GET() { @@ -8,13 +7,6 @@ export async function GET() { if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!hasProFeatures(user.plan)) { - return NextResponse.json( - { error: "API rate limits are available on the Pro plan only" }, - { status: 403 }, - ); - } - const status = await getApiRateLimitStatus(user.id); return NextResponse.json(status); } diff --git a/app/api/account/billing-portal/route.ts b/app/api/account/billing-portal/route.ts deleted file mode 100644 index a42d02c..0000000 --- a/app/api/account/billing-portal/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; -import { getSessionUser } from "@/lib/session"; -import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; - -/** - * Stripe Customer Portal update payment method, view invoices, cancel (per Dashboard config). - * @see https://docs.stripe.com/customer-management/integrate-customer-portal - */ -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (isBillingDevMock()) { - return NextResponse.json( - { error: "Billing portal is unavailable in mock mode." }, - { status: 503 }, - ); - } - - if (!isStripeConfigured()) { - return NextResponse.json( - { error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." }, - { status: 503 }, - ); - } - - const dbUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { stripeCustomerId: true }, - }); - - if (!dbUser.stripeCustomerId) { - return NextResponse.json( - { error: "No Stripe customer on this account. Complete a checkout first." }, - { status: 400 }, - ); - } - - const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin; - const stripe = getStripe(); - - try { - const session = await stripe.billingPortal.sessions.create({ - customer: dbUser.stripeCustomerId, - return_url: `${origin}/dashboard/settings`, - }); - return NextResponse.json({ url: session.url }); - } catch (err) { - const message = err instanceof Error ? err.message : "Could not open billing portal"; - console.error("[stripe] billing portal failed", err); - return NextResponse.json( - { - error: - message.includes("No configuration") || message.includes("portal") - ? "Billing portal is not configured in Stripe Dashboard yet. Enable it under Settings → Billing → Customer portal." - : message, - }, - { status: 502 }, - ); - } -} diff --git a/app/api/account/cancel-subscription/route.ts b/app/api/account/cancel-subscription/route.ts deleted file mode 100644 index ab93490..0000000 --- a/app/api/account/cancel-subscription/route.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { downgradeToFreePlan } from "@/lib/billing"; -import { prisma } from "@/lib/db"; -import { getSessionUser } from "@/lib/session"; -import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; - -type CancelWhen = "immediate" | "period_end"; -type CancelAction = "cancel" | "resume"; - -function periodEndIso(sub: { - cancel_at?: number | null; - current_period_end?: number; - items?: { data?: Array<{ current_period_end?: number }> }; -}): string | null { - const fromItem = sub.items?.data?.[0]?.current_period_end; - const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null; - return ts ? new Date(ts * 1000).toISOString() : null; -} - -/** - * Cancel Pro subscription via Stripe. - * Body: { when: "immediate" | "period_end" } or { action: "resume" } - */ -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (user.plan !== "PREMIUM") { - return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 }); - } - - let action: CancelAction = "cancel"; - let when: CancelWhen = "immediate"; - try { - const body = await req.json(); - if (body?.action === "resume") action = "resume"; - if (body?.when === "period_end" || body?.when === "immediate") when = body.when; - } catch { - // empty body → default immediate cancel (legacy) - } - - const dbUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { stripeSubscriptionId: true }, - }); - - const subId = dbUser.stripeSubscriptionId; - const isMockSub = !subId || subId.startsWith("dev_sub_"); - const useStripe = - Boolean(subId) && - !isMockSub && - isStripeConfigured() && - !isBillingDevMock(); - - // Resume (undo cancel-at-period-end) - if (action === "resume") { - if (useStripe && subId) { - try { - const stripe = getStripe(); - const sub = await stripe.subscriptions.update(subId, { - cancel_at_period_end: false, - }); - return NextResponse.json({ - ok: true, - action: "resume", - cancelAtPeriodEnd: false, - currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }), - }); - } catch (err) { - console.error("[stripe] resume subscription failed", err); - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to keep subscription" }, - { status: 502 }, - ); - } - } - return NextResponse.json({ - ok: true, - action: "resume", - cancelAtPeriodEnd: false, - mocked: true, - }); - } - - // Cancel - if (when === "period_end") { - if (useStripe && subId) { - try { - const stripe = getStripe(); - const sub = await stripe.subscriptions.update(subId, { - cancel_at_period_end: true, - }); - const endsAt = periodEndIso( - sub as { - cancel_at?: number | null; - current_period_end?: number; - items?: { data?: Array<{ current_period_end?: number }> }; - }, - ); - return NextResponse.json({ - ok: true, - when: "period_end", - cancelAtPeriodEnd: true, - endsAt, - // Keep Pro until Stripe sends customer.subscription.deleted - }); - } catch (err) { - console.error("[stripe] cancel at period end failed", err); - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to schedule cancellation" }, - { status: 502 }, - ); - } - } - - // Mock / no Stripe id: schedule locally by leaving Pro and reporting next month - const endsAt = new Date(); - endsAt.setMonth(endsAt.getMonth() + 1); - return NextResponse.json({ - ok: true, - when: "period_end", - cancelAtPeriodEnd: true, - endsAt: endsAt.toISOString(), - mocked: true, - }); - } - - // immediate - if (useStripe && subId) { - try { - const stripe = getStripe(); - await stripe.subscriptions.cancel(subId); - } catch (err) { - console.error("[stripe] cancel subscription failed", err); - return NextResponse.json( - { - error: - err instanceof Error - ? err.message - : "Stripe could not cancel the subscription. Your plan was not changed.", - }, - { status: 502 }, - ); - } - } - - await downgradeToFreePlan(user.id); - return NextResponse.json({ - ok: true, - when: "immediate", - cancelAtPeriodEnd: false, - plan: "FREE", - }); -} - -/** Current Stripe subscription cancel status for settings UI. */ -export async function GET() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (user.plan !== "PREMIUM") { - return NextResponse.json({ - plan: user.plan, - cancelAtPeriodEnd: false, - endsAt: null, - }); - } - - const dbUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { stripeSubscriptionId: true }, - }); - - const subId = dbUser.stripeSubscriptionId; - if ( - !subId || - subId.startsWith("dev_sub_") || - !isStripeConfigured() || - isBillingDevMock() - ) { - return NextResponse.json({ - plan: "PREMIUM", - cancelAtPeriodEnd: false, - endsAt: null, - mocked: true, - }); - } - - try { - const stripe = getStripe(); - const sub = await stripe.subscriptions.retrieve(subId); - return NextResponse.json({ - plan: "PREMIUM", - status: sub.status, - cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end), - endsAt: periodEndIso( - sub as { - cancel_at?: number | null; - current_period_end?: number; - items?: { data?: Array<{ current_period_end?: number }> }; - }, - ), - }); - } catch (err) { - console.error("[stripe] retrieve subscription failed", err); - return NextResponse.json({ - plan: "PREMIUM", - cancelAtPeriodEnd: false, - endsAt: null, - error: "Could not load subscription status from Stripe", - }); - } -} diff --git a/app/api/account/credits/route.ts b/app/api/account/credits/route.ts deleted file mode 100644 index 2b560f5..0000000 --- a/app/api/account/credits/route.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - CREDIT_PRICE_CENTS, - FREE_EXTRA_CREDITS_MAX, - FREE_TOP_UP_MAX, - FREE_TOP_UP_MIN, - formatCreditPrice, - validateFreeTopUpAmount, -} from "@/lib/credits"; -import { prisma } from "@/lib/db"; -import { getSessionUser } from "@/lib/session"; -import { - checkoutTaxAndReferenceOptions, - ensureStripeCustomer, - priceDataTaxFields, - productDataWithTaxCode, -} from "@/lib/stripe-checkout"; -import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; -import { grantExtraCredits } from "@/lib/billing"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const dbUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { - extraCredits: true, - plan: true, - monthlyCredits: true, - videosUsed: true, - }, - }); - - const purchases = await prisma.creditPurchase.findMany({ - where: { userId: user.id, status: "COMPLETED" }, - orderBy: { completedAt: "desc" }, - take: 10, - }); - - const cap = FREE_EXTRA_CREDITS_MAX; - const room = Math.max(0, cap - dbUser.extraCredits); - - return NextResponse.json({ - extraCredits: dbUser.extraCredits, - videoCredits: dbUser.extraCredits, - topUpMin: FREE_TOP_UP_MIN, - topUpMax: FREE_TOP_UP_MAX, - priceCentsPerCredit: CREDIT_PRICE_CENTS, - priceLabelPerCredit: formatCreditPrice(1), - creditBalanceMax: cap, - monthlyCredits: dbUser.monthlyCredits, - creditsUsed: dbUser.videosUsed, - plan: dbUser.plan, - stripeConfigured: isStripeConfigured() || isBillingDevMock(), - room, - atExtraCap: room === 0, - purchases: purchases.map((p) => ({ - id: p.id, - credits: p.credits, - amountCents: p.amountCents, - completedAt: p.completedAt?.toISOString() ?? null, - })), - }); -} - -/** Free tier: top up 1–15 extra credits (price = credits × €0.25). */ -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (user.plan === "PREMIUM") { - return NextResponse.json( - { - error: - "Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.", - }, - { status: 403 }, - ); - } - - let credits = 0; - try { - const body = await req.json(); - credits = Math.floor(Number(body.credits)); - } catch { - return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); - } - - const dbUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true }, - }); - - const monthlyRemaining = Math.max( - 0, - dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed, - ); - const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining); - if (!validation.ok) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - - const { credits: amount, amountCents } = validation; - - if (isBillingDevMock()) { - const purchase = await prisma.creditPurchase.create({ - data: { - userId: user.id, - credits: amount, - amountCents, - status: "COMPLETED", - completedAt: new Date(), - stripeSessionId: `dev_topup_${Date.now()}`, - }, - }); - await grantExtraCredits(user.id, amount); - return NextResponse.json({ - mocked: true, - granted: amount, - amountCents, - purchaseId: purchase.id, - }); - } - - if (!isStripeConfigured()) { - return NextResponse.json( - { error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." }, - { status: 503 }, - ); - } - - const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin; - const purchase = await prisma.creditPurchase.create({ - data: { - userId: user.id, - credits: amount, - amountCents, - status: "PENDING", - }, - }); - - try { - const stripe = getStripe(); - const customerId = await ensureStripeCustomer(user.id, dbUser.email); - const session = await stripe.checkout.sessions.create({ - mode: "payment", - customer: customerId, - line_items: [ - { - quantity: amount, - price_data: { - currency: "eur", - unit_amount: CREDIT_PRICE_CENTS, - ...priceDataTaxFields(), - product_data: productDataWithTaxCode( - "Songs2VID video credit", - `1 credit = 1 video upload (${formatCreditPrice(1)} each)`, - ), - }, - }, - ], - metadata: { - type: "free_top_up", - userId: user.id, - purchaseId: purchase.id, - credits: String(amount), - }, - ...checkoutTaxAndReferenceOptions(user.id), - success_url: `${origin}/dashboard/settings?credits=success`, - cancel_url: `${origin}/dashboard/settings?credits=cancelled`, - }); - - await prisma.creditPurchase.update({ - where: { id: purchase.id }, - data: { stripeSessionId: session.id }, - }); - - return NextResponse.json({ url: session.url, sessionId: session.id }); - } catch (err) { - await prisma.creditPurchase.update({ - where: { id: purchase.id }, - data: { status: "FAILED" }, - }); - const message = err instanceof Error ? err.message : "Checkout failed"; - return NextResponse.json({ error: message }, { status: 500 }); - } -} diff --git a/app/api/account/quota-extension-request/route.ts b/app/api/account/quota-extension-request/route.ts deleted file mode 100644 index 2111378..0000000 --- a/app/api/account/quota-extension-request/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions"; -import { getSessionUser } from "@/lib/session"; - -export async function GET() { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const usage = await getQuotaExtensionUsage(user.id); - return NextResponse.json(usage); -} - -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let message = ""; - try { - const body = await req.json(); - if (typeof body.message === "string") message = body.message; - } catch { - // optional body - } - - try { - const result = await createQuotaExtensionRequest(user.id, message); - return NextResponse.json(result); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to submit request"; - return NextResponse.json({ error: msg }, { status: 400 }); - } -} diff --git a/app/api/account/subscribe/route.ts b/app/api/account/subscribe/route.ts deleted file mode 100644 index 30a697d..0000000 --- a/app/api/account/subscribe/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { activateProPlan } from "@/lib/billing"; -import { PRO_PRICE_CENTS } from "@/lib/credits"; -import { prisma } from "@/lib/db"; -import { getSessionUser } from "@/lib/session"; -import { - checkoutTaxAndReferenceOptions, - ensureStripeCustomer, - priceDataTaxFields, - productDataWithTaxCode, -} from "@/lib/stripe-checkout"; -import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; - -/** Start Pro subscription Checkout (€5/mo) or mock-upgrade in development. */ -export async function POST(req: NextRequest) { - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - if (user.plan === "PREMIUM") { - return NextResponse.json({ error: "Already on Pro." }, { status: 400 }); - } - - const dbUser = await prisma.user.findUniqueOrThrow({ - where: { id: user.id }, - select: { email: true, stripeCustomerId: true }, - }); - - if (isBillingDevMock()) { - await activateProPlan(user.id, { - stripeCustomerId: dbUser.stripeCustomerId, - stripeSubscriptionId: `dev_sub_${Date.now()}`, - cardLast4: "4242", - }); - return NextResponse.json({ mocked: true, plan: "PREMIUM" }); - } - - if (!isStripeConfigured()) { - return NextResponse.json( - { error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." }, - { status: 503 }, - ); - } - - const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin; - const stripe = getStripe(); - const customerId = await ensureStripeCustomer(user.id, dbUser.email); - const priceId = process.env.STRIPE_PRO_PRICE_ID; - - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - customer: customerId, - line_items: priceId - ? [{ price: priceId, quantity: 1 }] - : [ - { - quantity: 1, - price_data: { - currency: "eur", - unit_amount: PRO_PRICE_CENTS, - recurring: { interval: "month" }, - ...priceDataTaxFields(), - product_data: productDataWithTaxCode( - "Songs2VID Pro", - "50 video credits / month · 1080p · API access · Includes Custom Branding, Watermark Positioning, and Bulk Image Matching", - ), - }, - }, - ], - metadata: { - type: "pro_subscription", - userId: user.id, - }, - subscription_data: { - metadata: { - type: "pro_subscription", - userId: user.id, - }, - }, - ...checkoutTaxAndReferenceOptions(user.id), - success_url: `${origin}/dashboard/settings?upgrade=success`, - cancel_url: `${origin}/dashboard/settings?upgrade=cancelled`, - }); - - return NextResponse.json({ url: session.url, sessionId: session.id }); -} diff --git a/app/api/admin/badges/[id]/route.ts b/app/api/admin/badges/[id]/route.ts deleted file mode 100644 index e2d0f2d..0000000 --- a/app/api/admin/badges/[id]/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { requireAdmin } from "@/lib/admin-auth"; -import { prisma } from "@/lib/db"; -import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges"; - -type Ctx = { params: Promise<{ id: string }> }; - -export async function PATCH(req: NextRequest, ctx: Ctx) { - const denied = requireAdmin(req); - if (denied) return denied; - - const { id } = await ctx.params; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - const existing = await prisma.footerBadge.findUnique({ where: { id } }); - if (!existing) { - return NextResponse.json({ error: "Badge not found" }, { status: 404 }); - } - - const input = (body ?? {}) as Record; - const patch: { - name?: string; - embedHtml?: string | null; - imageUrl?: string | null; - linkUrl?: string | null; - isActive?: boolean; - sortOrder?: number; - } = {}; - - if ("name" in input) patch.name = typeof input.name === "string" ? input.name : existing.name; - if ("embedHtml" in input) { - patch.embedHtml = typeof input.embedHtml === "string" ? input.embedHtml : null; - } - if ("imageUrl" in input) { - patch.imageUrl = typeof input.imageUrl === "string" ? input.imageUrl : null; - } - if ("linkUrl" in input) { - patch.linkUrl = typeof input.linkUrl === "string" ? input.linkUrl : null; - } - if ("isActive" in input && typeof input.isActive === "boolean") { - patch.isActive = input.isActive; - } - if ("sortOrder" in input && typeof input.sortOrder === "number") { - patch.sortOrder = input.sortOrder; - } - - const fields = normalizeBadgeFields({ - name: patch.name ?? existing.name, - embedHtml: "embedHtml" in patch ? patch.embedHtml : existing.embedHtml, - imageUrl: "imageUrl" in patch ? patch.imageUrl : existing.imageUrl, - linkUrl: "linkUrl" in patch ? patch.linkUrl : existing.linkUrl, - isActive: patch.isActive ?? existing.isActive, - sortOrder: patch.sortOrder ?? existing.sortOrder, - }); - - // Toggle-only updates should not re-validate empty content - const contentChanging = - "name" in input || "embedHtml" in input || "imageUrl" in input || "linkUrl" in input; - if (contentChanging) { - const error = validateBadgeFields(fields); - if (error) return NextResponse.json({ error }, { status: 400 }); - } - - const badge = await prisma.footerBadge.update({ - where: { id }, - data: contentChanging - ? fields - : { - ...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}), - ...(patch.sortOrder !== undefined ? { sortOrder: fields.sortOrder } : {}), - }, - }); - - return NextResponse.json({ badge }); -} - -export async function DELETE(req: NextRequest, ctx: Ctx) { - const denied = requireAdmin(req); - if (denied) return denied; - - const { id } = await ctx.params; - const existing = await prisma.footerBadge.findUnique({ where: { id } }); - if (!existing) { - return NextResponse.json({ error: "Badge not found" }, { status: 404 }); - } - - await prisma.footerBadge.delete({ where: { id } }); - return NextResponse.json({ ok: true }); -} diff --git a/app/api/admin/badges/route.ts b/app/api/admin/badges/route.ts deleted file mode 100644 index 8a7421d..0000000 --- a/app/api/admin/badges/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { requireAdmin } from "@/lib/admin-auth"; -import { prisma } from "@/lib/db"; -import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges"; - -export async function GET(req: NextRequest) { - const denied = requireAdmin(req); - if (denied) return denied; - - const badges = await prisma.footerBadge.findMany({ - orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], - }); - - return NextResponse.json({ badges }); -} - -export async function POST(req: NextRequest) { - const denied = requireAdmin(req); - if (denied) return denied; - - let body: unknown; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - const input = (body ?? {}) as Record; - const fields = normalizeBadgeFields({ - name: typeof input.name === "string" ? input.name : "", - embedHtml: typeof input.embedHtml === "string" ? input.embedHtml : null, - imageUrl: typeof input.imageUrl === "string" ? input.imageUrl : null, - linkUrl: typeof input.linkUrl === "string" ? input.linkUrl : null, - isActive: typeof input.isActive === "boolean" ? input.isActive : true, - sortOrder: typeof input.sortOrder === "number" ? input.sortOrder : undefined, - }); - - const error = validateBadgeFields(fields); - if (error) return NextResponse.json({ error }, { status: 400 }); - - if (fields.sortOrder === 0) { - const max = await prisma.footerBadge.aggregate({ _max: { sortOrder: true } }); - fields.sortOrder = (max._max.sortOrder ?? -1) + 1; - } - - const badge = await prisma.footerBadge.create({ data: fields }); - return NextResponse.json({ badge }, { status: 201 }); -} diff --git a/app/api/admin/quota-extension-request/[id]/route.ts b/app/api/admin/quota-extension-request/[id]/route.ts deleted file mode 100644 index 48bdde5..0000000 --- a/app/api/admin/quota-extension-request/[id]/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - approveQuotaExtensionRequest, - rejectQuotaExtensionRequest, -} from "@/lib/quota-extensions"; - -function isAuthorized(req: NextRequest) { - const key = process.env.ADMIN_API_KEY; - if (!key) return false; - const auth = req.headers.get("authorization"); - return auth === `Bearer ${key}`; -} - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - if (!isAuthorized(req)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - let action: "approve" | "reject" = "approve"; - let bonusQuota: number | undefined; - let bonusRateLimit: number | undefined; - let adminNote: string | undefined; - - try { - const body = await req.json(); - if (body.action === "reject") action = "reject"; - if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) { - bonusQuota = body.bonusQuota; - } - if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) { - bonusRateLimit = body.bonusRateLimit; - } - if (typeof body.adminNote === "string") adminNote = body.adminNote; - } catch { - // defaults - } - - try { - if (action === "reject") { - await rejectQuotaExtensionRequest(id, adminNote); - } else { - await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote }); - } - return NextResponse.json({ ok: true }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to process request"; - return NextResponse.json({ error: msg }, { status: 400 }); - } -} diff --git a/app/api/admin/reset-credits/route.ts b/app/api/admin/reset-credits/route.ts deleted file mode 100644 index a4f53c2..0000000 --- a/app/api/admin/reset-credits/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { adminResetUserCredits } from "@/lib/billing"; -import { prisma } from "@/lib/db"; - -/** - * Admin utility: reset / adjust a user's credits. - * Authorization: Bearer ${ADMIN_API_KEY} - * - * Body: { userId | email, plan?, monthlyCredits?, creditsUsed?, extraCredits?, clearFreeTopUp? } - */ -export async function POST(req: NextRequest) { - const adminKey = process.env.ADMIN_API_KEY; - if (!adminKey) { - return NextResponse.json({ error: "Admin API not configured" }, { status: 503 }); - } - - const auth = req.headers.get("authorization"); - if (auth !== `Bearer ${adminKey}`) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: { - userId?: string; - email?: string; - plan?: "FREE" | "PREMIUM"; - monthlyCredits?: number; - creditsUsed?: number; - extraCredits?: number; - clearFreeTopUp?: boolean; - }; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - let userId = body.userId; - if (!userId && body.email) { - const found = await prisma.user.findUnique({ where: { email: body.email } }); - if (!found) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - userId = found.id; - } - if (!userId) { - return NextResponse.json({ error: "Provide userId or email" }, { status: 400 }); - } - - const updated = await adminResetUserCredits(userId, { - plan: body.plan, - monthlyCredits: body.monthlyCredits, - creditsUsed: body.creditsUsed, - extraCredits: body.extraCredits, - clearFreeTopUp: body.clearFreeTopUp, - }); - - return NextResponse.json({ - ok: true, - user: { - id: updated.id, - email: updated.email, - plan: updated.plan, - monthlyCredits: updated.monthlyCredits, - creditsUsed: updated.videosUsed, - extraCredits: updated.extraCredits, - freeTopUpPurchased: updated.freeTopUpPurchased, - }, - }); -} diff --git a/app/api/badges/route.ts b/app/api/badges/route.ts deleted file mode 100644 index 7f11607..0000000 --- a/app/api/badges/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; - -/** Public: active footer badges for the landing marquee. */ -export async function GET() { - const badges = await prisma.footerBadge.findMany({ - where: { isActive: true }, - orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], - select: { - id: true, - name: true, - embedHtml: true, - imageUrl: true, - linkUrl: true, - sortOrder: true, - }, - }); - - return NextResponse.json({ badges }, { - headers: { - "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", - }, - }); -} diff --git a/app/api/dev/grant-credits/route.ts b/app/api/dev/grant-credits/route.ts deleted file mode 100644 index 2e9d825..0000000 --- a/app/api/dev/grant-credits/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - activateProPlan, - adminResetUserCredits, - grantExtraCredits, - markFreeTopUpPurchased, -} from "@/lib/billing"; -import { FREE_TOP_UP_CREDITS } from "@/lib/credits"; -import { isBillingDevMock } from "@/lib/stripe"; -import { getSessionUser } from "@/lib/session"; - -/** - * Local-only billing helpers (no Stripe CLI required). - * Enabled when NODE_ENV=development (unless BILLING_DEV_MOCK=false) or BILLING_DEV_MOCK=true. - */ -function assertDev() { - if (!isBillingDevMock()) { - return NextResponse.json({ error: "Not available outside development mock mode" }, { status: 404 }); - } - return null; -} - -export async function POST(req: NextRequest) { - const blocked = assertDev(); - if (blocked) return blocked; - - const user = await getSessionUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - let body: { - action?: string; - credits?: number; - plan?: "FREE" | "PREMIUM"; - }; - try { - body = await req.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); - } - - const action = body.action ?? "grant-credits"; - - switch (action) { - case "grant-credits": { - const credits = Math.floor(Number(body.credits ?? FREE_TOP_UP_CREDITS)); - if (!Number.isFinite(credits) || credits <= 0) { - return NextResponse.json({ error: "Invalid credits" }, { status: 400 }); - } - await grantExtraCredits(user.id, credits); - return NextResponse.json({ ok: true, granted: credits }); - } - case "free-top-up": { - await markFreeTopUpPurchased(user.id); - return NextResponse.json({ ok: true, granted: FREE_TOP_UP_CREDITS }); - } - case "set-pro": { - await activateProPlan(user.id, { - stripeSubscriptionId: `dev_sub_${Date.now()}`, - cardLast4: "4242", - }); - return NextResponse.json({ ok: true, plan: "PREMIUM" }); - } - case "set-free": { - await adminResetUserCredits(user.id, { plan: "FREE", creditsUsed: 0 }); - return NextResponse.json({ ok: true, plan: "FREE" }); - } - case "reset-cycle": { - await adminResetUserCredits(user.id, { - plan: body.plan, - creditsUsed: 0, - }); - return NextResponse.json({ ok: true, reset: true }); - } - default: - return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 }); - } -} - -/** Convenience GET for quick browser testing: /api/dev/grant-credits?action=set-pro */ -export async function GET(req: NextRequest) { - const blocked = assertDev(); - if (blocked) return blocked; - - const action = req.nextUrl.searchParams.get("action") ?? "grant-credits"; - const credits = Number(req.nextUrl.searchParams.get("credits") ?? FREE_TOP_UP_CREDITS); - - const fake = new NextRequest(req.url, { - method: "POST", - headers: { "content-type": "application/json", cookie: req.headers.get("cookie") ?? "" }, - body: JSON.stringify({ action, credits }), - }); - return POST(fake); -} diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts index 55bca49..f51c32c 100644 --- a/app/api/jobs/route.ts +++ b/app/api/jobs/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; import { createVideoJob } from "@/lib/jobs/create-job"; import { prisma } from "@/lib/db"; import { requireAuth } from "@/lib/session"; @@ -17,12 +16,8 @@ export async function POST(req: NextRequest) { const job = await createVideoJob(user, body); return NextResponse.json({ jobId: job.id }); } catch (err) { - if (err instanceof PremiumRequiredError) { - return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); - } const message = err instanceof Error ? err.message : "Failed to create job"; - const status = message.includes("Quota exceeded") ? 403 : 400; - return NextResponse.json({ error: message }, { status }); + return NextResponse.json({ error: message }, { status: 400 }); } } diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts deleted file mode 100644 index d0351f1..0000000 --- a/app/api/stripe/webhook/route.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import type Stripe from "stripe"; -import { - activateProPlan, - applyMonthlyCreditRenewal, - downgradeToFreePlan, - grantExtraCredits, -} from "@/lib/billing"; -import { monthlyCreditsForPlan } from "@/lib/credits"; -import { prisma } from "@/lib/db"; -import { getNextMonthlyQuotaReset } from "@/lib/plans"; -import { getStripe } from "@/lib/stripe"; - -export const runtime = "nodejs"; - -/** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */ -function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null { - const legacy = (invoice as Stripe.Invoice & { - subscription?: string | Stripe.Subscription | null; - }).subscription; - if (typeof legacy === "string" && legacy) return legacy; - if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) { - return String(legacy.id); - } - - const parent = ( - invoice as Stripe.Invoice & { - parent?: { - type?: string | null; - subscription_details?: { subscription?: string | Stripe.Subscription | null } | null; - } | null; - } - ).parent; - - const fromParent = parent?.subscription_details?.subscription; - if (typeof fromParent === "string" && fromParent) return fromParent; - if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) { - return String(fromParent.id); - } - return null; -} - -async function fulfillFreeTopUp(session: Stripe.Checkout.Session) { - const purchaseId = session.metadata?.purchaseId; - const userId = session.metadata?.userId; - if (!purchaseId || !userId) { - console.error("[stripe] free_top_up missing metadata", session.id); - return; - } - - const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } }); - if (!purchase || purchase.userId !== userId) { - throw new Error(`Purchase not found: ${purchaseId}`); - } - if (purchase.status === "COMPLETED") return; - - await grantExtraCredits(userId, purchase.credits); - - await prisma.creditPurchase.update({ - where: { id: purchaseId }, - data: { - status: "COMPLETED", - completedAt: new Date(), - stripeSessionId: session.id, - stripePaymentIntentId: - typeof session.payment_intent === "string" - ? session.payment_intent - : session.payment_intent?.id ?? null, - }, - }); -} - -async function fulfillProCheckout(session: Stripe.Checkout.Session) { - const userId = session.metadata?.userId; - if (!userId) { - console.error("[stripe] pro_subscription missing userId", session.id); - return; - } - - const subscriptionId = - typeof session.subscription === "string" - ? session.subscription - : session.subscription?.id ?? null; - - const customerId = - typeof session.customer === "string" ? session.customer : session.customer?.id ?? null; - - await activateProPlan(userId, { - stripeCustomerId: customerId, - stripeSubscriptionId: subscriptionId, - }); -} - -async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) { - const subscriptionId = subscriptionIdFromInvoice(invoice); - if (!subscriptionId) { - console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id); - return; - } - - // Skip the first invoice if checkout already activated Pro (billing_reason subscription_create) - const user = await prisma.user.findFirst({ - where: { stripeSubscriptionId: subscriptionId }, - }); - if (!user) { - // Fallback: metadata on subscription via Stripe retrieve is optional; try customer - const customerId = - typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; - if (!customerId) return; - const byCustomer = await prisma.user.findFirst({ - where: { stripeCustomerId: customerId }, - }); - if (!byCustomer) return; - await applyMonthlyCreditRenewal(byCustomer.id, { - plan: "PREMIUM", - newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), - quotaResetAt: getNextMonthlyQuotaReset(), - }); - await prisma.user.update({ - where: { id: byCustomer.id }, - data: { - stripeSubscriptionId: subscriptionId, - subscribedAt: byCustomer.subscribedAt ?? new Date(), - }, - }); - return; - } - - // Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30) - await applyMonthlyCreditRenewal(user.id, { - plan: "PREMIUM", - newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), - quotaResetAt: getNextMonthlyQuotaReset(), - }); -} - -async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { - const user = await prisma.user.findFirst({ - where: { stripeSubscriptionId: subscription.id }, - }); - if (!user) return; - await downgradeToFreePlan(user.id); -} - -/** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */ -async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { - const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]); - if (!terminal.has(subscription.status)) return; - - const user = await prisma.user.findFirst({ - where: { stripeSubscriptionId: subscription.id }, - }); - if (!user || user.plan !== "PREMIUM") return; - await downgradeToFreePlan(user.id); -} - -async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { - const subscriptionId = subscriptionIdFromInvoice(invoice); - if (!subscriptionId) return; - - const user = await prisma.user.findFirst({ - where: { - OR: [ - { stripeSubscriptionId: subscriptionId }, - ...(typeof invoice.customer === "string" - ? [{ stripeCustomerId: invoice.customer }] - : invoice.customer?.id - ? [{ stripeCustomerId: invoice.customer.id }] - : []), - ], - }, - }); - if (!user) return; - - // Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure. - console.warn( - "[stripe] invoice.payment_failed", - invoice.id, - "user", - user.id, - "attempt", - invoice.attempt_count, - ); -} - -export async function POST(req: NextRequest) { - const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; - if (!webhookSecret) { - return NextResponse.json({ error: "Webhook not configured" }, { status: 503 }); - } - - const signature = req.headers.get("stripe-signature"); - if (!signature) { - return NextResponse.json({ error: "Missing signature" }, { status: 400 }); - } - - const rawBody = await req.text(); - let event: Stripe.Event; - - try { - const stripe = getStripe(); - event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); - } catch (err) { - const message = err instanceof Error ? err.message : "Invalid signature"; - return NextResponse.json({ error: message }, { status: 400 }); - } - - try { - if (event.type === "checkout.session.completed") { - const session = event.data.object as Stripe.Checkout.Session; - if (session.payment_status === "paid" || session.status === "complete") { - const type = session.metadata?.type; - if (type === "pro_subscription" || session.mode === "subscription") { - await fulfillProCheckout(session); - } else if (type === "free_top_up") { - await fulfillFreeTopUp(session); - } else if (session.metadata?.purchaseId) { - // Legacy PAYG purchases: grant metadata credits as extras - const userId = session.metadata.userId; - const credits = Number(session.metadata.credits); - const purchaseId = session.metadata.purchaseId; - if (userId && purchaseId && Number.isFinite(credits) && credits > 0) { - const purchase = await prisma.creditPurchase.findUnique({ - where: { id: purchaseId }, - }); - if (purchase && purchase.status !== "COMPLETED") { - await grantExtraCredits(userId, credits); - await prisma.creditPurchase.update({ - where: { id: purchaseId }, - data: { - status: "COMPLETED", - completedAt: new Date(), - stripeSessionId: session.id, - }, - }); - } - } - } - } - } else if (event.type === "invoice.payment_succeeded") { - await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice); - } else if (event.type === "invoice.payment_failed") { - await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); - } else if (event.type === "customer.subscription.updated") { - await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); - } else if (event.type === "customer.subscription.deleted") { - await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); - } - } catch (err) { - console.error("[stripe] webhook handler error", event.type, event.id, err); - return NextResponse.json({ error: "Handler failed" }, { status: 500 }); - } - - return NextResponse.json({ received: true }); -} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 1abdf42..de1f2b2 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job"; import { getSessionUser } from "@/lib/session"; @@ -28,14 +27,11 @@ export async function POST(req: NextRequest) { } try { - const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, { + const result = await saveUploadedFile(user.id, file, type as UploadFileType, { sessionKey, }); return NextResponse.json(result); } catch (err) { - if (err instanceof PremiumRequiredError) { - return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); - } const message = err instanceof Error ? err.message : "Upload failed"; return NextResponse.json({ error: message }, { status: 400 }); } diff --git a/app/api/v1/jobs/[id]/route.ts b/app/api/v1/jobs/[id]/route.ts index aca6d3d..6b7e9ca 100644 --- a/app/api/v1/jobs/[id]/route.ts +++ b/app/api/v1/jobs/[id]/route.ts @@ -1,12 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { requirePaidApiUser } from "@/lib/api-auth"; +import { requireApiUser } from "@/lib/api-auth"; import { prisma } from "@/lib/db"; export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const { error, user } = await requirePaidApiUser(_req); + const { error, user } = await requireApiUser(_req); if (error || !user) return error!; const { id } = await params; diff --git a/app/api/v1/jobs/batch/route.ts b/app/api/v1/jobs/batch/route.ts index e0eb38f..882840d 100644 --- a/app/api/v1/jobs/batch/route.ts +++ b/app/api/v1/jobs/batch/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { Privacy } from "@prisma/client"; -import { requirePaidApiUser } from "@/lib/api-auth"; -import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { requireApiUser } from "@/lib/api-auth"; import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job"; import { applyCreatePlaylistToItems, @@ -41,7 +40,7 @@ function defaultMetadata(title: string): ItemMetadata { export async function POST(req: NextRequest) { try { - const { error, user } = await requirePaidApiUser(req); + const { error, user } = await requireApiUser(req); if (error || !user) return error!; const formData = await req.formData(); @@ -56,10 +55,10 @@ export async function POST(req: NextRequest) { ); } - const limits = getPlanLimits(user.plan); + const limits = getPlanLimits(); if (audioFiles.length > limits.maxBatchSize) { return NextResponse.json( - { error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` }, + { error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` }, { status: 400 }, ); } @@ -98,10 +97,10 @@ export async function POST(req: NextRequest) { } const sessionKey = Date.now().toString(); - const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey }); + const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey }); const uploads = await mapWithConcurrency(audioFiles, 4, (audio) => - saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }), + saveUploadedFile(user.id, audio, "audio", { sessionKey }), ); const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => { @@ -142,18 +141,8 @@ export async function POST(req: NextRequest) { }); } catch (err) { console.error("Batch upload failed:", err); - if (err instanceof PremiumRequiredError) { - return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); - } const message = err instanceof Error ? err.message : "Batch upload failed"; - const status = - message.includes("Batch limit exceeded") - ? 400 - : message.includes("Quota exceeded") || - message.includes("Not enough credits") || - message.includes("Payment Required") - ? 402 - : 500; + const status = message.includes("Batch limit exceeded") ? 400 : 500; return NextResponse.json({ error: message }, { status }); } } diff --git a/app/api/v1/jobs/route.ts b/app/api/v1/jobs/route.ts index 1baf3c3..e907e77 100644 --- a/app/api/v1/jobs/route.ts +++ b/app/api/v1/jobs/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { requirePaidApiUser } from "@/lib/api-auth"; -import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { requireApiUser } from "@/lib/api-auth"; import { createVideoJob } from "@/lib/jobs/create-job"; import { applyCreatePlaylistToItems, @@ -10,7 +9,7 @@ import { prisma } from "@/lib/db"; import type { CreateJobPayload } from "@/lib/types"; export async function POST(req: NextRequest) { - const { error, user } = await requirePaidApiUser(req); + const { error, user } = await requireApiUser(req); if (error || !user) return error!; const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown }; @@ -47,22 +46,13 @@ export async function POST(req: NextRequest) { : null, }); } catch (err) { - if (err instanceof PremiumRequiredError) { - return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); - } const message = err instanceof Error ? err.message : "Failed to create job"; - const status = - message.includes("Quota exceeded") || - message.includes("Not enough credits") || - message.includes("Payment Required") - ? 402 - : 400; - return NextResponse.json({ error: message }, { status }); + return NextResponse.json({ error: message }, { status: 400 }); } } export async function GET(req: NextRequest) { - const { error, user } = await requirePaidApiUser(req); + const { error, user } = await requireApiUser(req); if (error || !user) return error!; const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100); diff --git a/app/api/v1/playlists/route.ts b/app/api/v1/playlists/route.ts index 52523fc..87ff4c1 100644 --- a/app/api/v1/playlists/route.ts +++ b/app/api/v1/playlists/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { requirePaidApiUser } from "@/lib/api-auth"; +import { requireApiUser } from "@/lib/api-auth"; import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist"; import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload"; export async function GET(req: NextRequest) { - const { error, user } = await requirePaidApiUser(req); + const { error, user } = await requireApiUser(req); if (error || !user) return error!; try { @@ -18,7 +18,7 @@ export async function GET(req: NextRequest) { } export async function POST(req: NextRequest) { - const { error, user } = await requirePaidApiUser(req); + const { error, user } = await requireApiUser(req); if (error || !user) return error!; try { diff --git a/app/api/v1/route.ts b/app/api/v1/route.ts index 74255cc..6762c01 100644 --- a/app/api/v1/route.ts +++ b/app/api/v1/route.ts @@ -6,8 +6,8 @@ export async function GET() { name: "Songs2VID API", version: "1.0", authentication: "Authorization: Bearer ", - requirements: ["Pro subscription", "YouTube account connected"], - rateLimit: "60 requests per minute per account (plus any approved bonus)", + requirements: ["YouTube account connected"], + rateLimit: "100,000 requests per minute per account", guidance: { recommended: "For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths", @@ -19,14 +19,14 @@ export async function GET() { method: "POST", path: "/api/v1/upload", description: - "Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)", + "Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)", body: "multipart/form-data: file, type (image|audio|logo|font)", }, { method: "POST", path: "/api/v1/jobs", description: - "Create a video job from uploaded file paths. Pro: layout templates, blur, watermark, per-item covers", + "Create a video job from uploaded file paths with layouts, watermark, and per-item covers", body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }", }, { @@ -39,7 +39,7 @@ export async function GET() { { method: "GET", path: "/api/v1/playlists", - description: "List YouTube playlists for the authenticated Pro account", + description: "List YouTube playlists for the authenticated account", }, { method: "POST", diff --git a/app/api/v1/upload/route.ts b/app/api/v1/upload/route.ts index 2809834..92e80ee 100644 --- a/app/api/v1/upload/route.ts +++ b/app/api/v1/upload/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { requirePaidApiUser } from "@/lib/api-auth"; -import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements"; +import { requireApiUser } from "@/lib/api-auth"; import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job"; export const maxDuration = 120; export async function POST(req: NextRequest) { - const { error, user } = await requirePaidApiUser(req); + const { error, user } = await requireApiUser(req); if (error || !user) return error!; const formData = await req.formData(); @@ -25,12 +24,9 @@ export async function POST(req: NextRequest) { } try { - const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan); + const result = await saveUploadedFile(user.id, file, type as UploadFileType); return NextResponse.json(result); } catch (err) { - if (err instanceof PremiumRequiredError) { - return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 }); - } const message = err instanceof Error ? err.message : "Upload failed"; return NextResponse.json({ error: message }, { status: 400 }); } diff --git a/app/api/youtube/playlists/route.ts b/app/api/youtube/playlists/route.ts index 13f0c07..501022e 100644 --- a/app/api/youtube/playlists/route.ts +++ b/app/api/youtube/playlists/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { hasProFeatures } from "@/lib/edition"; import { getSessionUser } from "@/lib/session"; import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist"; import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload"; @@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() { if (!user) { return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null }; } - if (!hasProFeatures(user.plan)) { - return { - error: NextResponse.json( - { error: "Playlist features are available on the Pro plan only" }, - { status: 403 }, - ), - user: null, - }; - } if (!user.youtubeConnection) { return { error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }), diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx index 566e551..8359d0c 100644 --- a/app/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -4,23 +4,10 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { AccountPrivacyActions } from "@/components/AccountPrivacyActions"; -import { CreditPurchasePanel } from "@/components/CreditPurchasePanel"; -import { PlanBillingActions } from "@/components/PlanBillingActions"; import { DashboardShell } from "@/components/DashboardShell"; -import { UpgradeProButton } from "@/components/UpgradeProButton"; import { getUserApiKeyStatus } from "@/lib/api-keys"; import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; import { ApiKeySettings } from "@/components/ApiKeySettings"; -import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition"; -import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions"; -import { getQuotaInfo } from "@/lib/quota"; -import { isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; -import { CreditPurchaseStatus } from "@prisma/client"; - -const PLAN_LABELS = { - FREE: "Bedroom Producer", - PREMIUM: "Pro", -} as const; function InfoRow({ label, value }: { label: string; value: string }) { return ( @@ -41,22 +28,10 @@ export default async function SettingsPage() { }); if (!user) redirect("/"); - const quota = await getQuotaInfo(user.id); - const selfHosted = isSelfHostedEdition(); - const proFeatures = hasProFeatures(user.plan); - const recentCreditPurchases = await prisma.creditPurchase.findMany({ - where: { userId: user.id, status: CreditPurchaseStatus.COMPLETED }, - orderBy: { completedAt: "desc" }, - take: 5, - }); - const extensionUsage = - !selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null; - const apiRateExtensionUsage = proFeatures - ? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT) - : null; - const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null; - const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null; - const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan]; + const [apiKeyStatus, apiRateLimit] = await Promise.all([ + getUserApiKeyStatus(user.id), + getApiRateLimitStatus(user.id), + ]); const youtube = user.youtubeConnection; const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null; @@ -64,174 +39,51 @@ export default async function SettingsPage() {

Settings

-
-

Account information

+

Account

+
- -
-

- YouTube -

- {youtube ? ( -
- - -
- ) : ( -

YouTube account not connected.

- )} - - {channelUrl && ( - - Go to your channel - - )} - -

- To refresh your YouTube permissions,{" "} - - sign out and sign in again - - . -

-
-

Plan & billing

- -
-
-
-

- Video quota remaining -

-

- {quota.totalAvailable} - - {" "} - of {quota.limit + quota.extraCredits} videos - -

-
-

- {quota.used} monthly used - {quota.extraCredits > 0 ? ` · ${quota.extraCredits} extras` : ""} - {quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""} -

-
-
-
-
-

- {`Includes monthly + purchased extras · monthly resets on ${quota.resetsIn}`} -

-
- -
- - - - {user.plan === "PREMIUM" && user.subscribedAt && ( - - )} - {user.plan === "PREMIUM" && ( - - )} - {extensionUsage && ( - - )} - - -
- - {user.plan === "FREE" ? ( - <> -

- for 50 videos/month, 1080p, API access, and lossless audio. -

- {!selfHosted && ( - ({ - id: p.id, - credits: p.credits, - amountCents: p.amountCents, - completedAt: p.completedAt?.toISOString() ?? null, - }))} - /> - )} - +

YouTube

+ {youtube ? ( +
+ + +
) : ( - <> - {extensionUsage && } -

- Pro uses your monthly allocation first, then any never-expiring extra credits. -

- +

YouTube account not connected.

)} + {channelUrl && ( + + Go to your channel + + )} +

+ To refresh YouTube permissions,{" "} + sign out and sign in again. +

- {proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && ( -
-

API access

- -
- )} +
+

API key

+ +

Account deletion & data

- Request a copy of your data or permanently delete your account and associated uploads. + Request a copy of your data or permanently delete your account and uploads.

diff --git a/app/layout.tsx b/app/layout.tsx index 0ddae53..d58fffd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,75 +2,18 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import { Providers } from "./providers"; -import { JsonLd } from "@/components/JsonLd"; -import { Matomo } from "@/components/Matomo"; const inter = Inter({ subsets: ["latin"] }); -const SITE_URL = "https://songs2vid.com"; const SITE_NAME = "Songs2VID"; -const DEFAULT_TITLE = - "Songs2VID - Fast Audio to Video Converter for YouTube | TunesToTube Alternative"; +const DEFAULT_TITLE = "Songs2VID"; const DEFAULT_DESCRIPTION = - "Convert MP3, WAV, and audio files into YouTube videos instantly. The fastest, privacy-focused online tool to upload music, beats, and podcasts directly to YouTube."; -const OG_TITLE = "Songs2VID - Fast Audio to Video Converter for YouTube"; -const OG_DESCRIPTION = - "Convert MP3 & audio files into YouTube videos instantly without heavy editing."; -const TWITTER_DESCRIPTION = "Convert MP3 & audio files into YouTube videos instantly."; + "Self-hosted audio-to-video creation and YouTube uploading."; export const metadata: Metadata = { - metadataBase: new URL(SITE_URL), - title: { - default: DEFAULT_TITLE, - template: `%s | ${SITE_NAME}`, - }, + title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION, - keywords: [ - "mp3 to youtube", - "audio to video converter", - "upload audio to youtube", - "tunestotube alternative", - "song to video", - "podcast to youtube", - "convert mp3 to mp4", - ], - authors: [{ name: SITE_NAME, url: SITE_URL }], - creator: SITE_NAME, - publisher: SITE_NAME, applicationName: SITE_NAME, - alternates: { - canonical: SITE_URL, - }, - openGraph: { - title: OG_TITLE, - description: OG_DESCRIPTION, - url: SITE_URL, - siteName: SITE_NAME, - type: "website", - locale: "en_US", - images: [ - { - url: "/logo.png", - width: 512, - height: 512, - alt: SITE_NAME, - }, - ], - }, - twitter: { - card: "summary_large_image", - title: OG_TITLE, - description: TWITTER_DESCRIPTION, - images: ["/logo.png"], - }, - robots: { - index: true, - follow: true, - googleBot: { - index: true, - follow: true, - }, - }, icons: { icon: "/favicon.png", }, @@ -84,8 +27,6 @@ export default function RootLayout({ return ( - - {children} diff --git a/app/page.tsx b/app/page.tsx index 712a8e1..5f8f6df 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,94 +1,27 @@ import Link from "next/link"; +import { redirect } from "next/navigation"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { BenefitsSection } from "@/components/BenefitsSection"; -import { DownloadSection } from "@/components/DownloadSection"; -import { LandingNavbar } from "@/components/LandingNavbar"; +import { Logo } from "@/components/Logo"; import { SignInButton } from "@/components/SignInButton"; -import { PricingSection } from "@/components/PricingSection"; -import { StepsSection } from "@/components/StepsSection"; -import { Footer } from "@/components/Footer"; -import { SupportSection } from "@/components/SupportSection"; export default async function HomePage() { const session = await getServerSession(authOptions); + if (session) redirect("/dashboard"); return ( -
-
- -
- - - - - - - - - -
+ +

+ By continuing, you accept the{" "} + Terms and{" "} + Privacy Policy. +

+
); } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 4ce67dd..2bed7fb 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -1,314 +1,15 @@ -import type { Metadata } from "next"; import { LegalPageLayout } from "@/components/LegalPageLayout"; -import { LEGAL_OPERATOR } from "@/lib/legal/constants"; - -export const metadata: Metadata = { - title: "Privacy Policy", - description: "How Songs2VID collects, uses, and protects your personal data.", - alternates: { canonical: "https://songs2vid.com/privacy" }, -}; export default function PrivacyPage() { return ( - -

1. Overview

+

- This Privacy Policy explains how {LEGAL_OPERATOR.name} ("we", "us") - processes personal data when you use our website, hosted cloud service, and related features - that convert audio and images into videos for upload to YouTube. + Songs2VID is self-hosted software. Your operator controls the deployment, database, uploads, + logs, and Google OAuth configuration. Songs2VID does not include payment processing.

- We process personal data in accordance with applicable data protection laws, including the - General Data Protection Regulation (GDPR) where it applies. -

- -

2. Data controller

-

- {LEGAL_OPERATOR.legalName} -
- {LEGAL_OPERATOR.address} -
- {LEGAL_OPERATOR.city} -
- Email: {LEGAL_OPERATOR.email} -

- -

3. What data we collect

-

3.1 Account and authentication data

-

When you sign in with Google, we receive and store:

-
    -
  • Your name, email address, and profile image (from Google)
  • -
  • OAuth tokens required to authenticate your session
  • -
  • YouTube connection data, including channel ID and title
  • -
  • - Encrypted YouTube API access and refresh tokens needed to upload videos, create or list - playlists, and manage related YouTube actions you request -
  • -
- -

3.2 Uploaded content and job data

-

When you use the service, we temporarily process:

-
    -
  • Image and audio files you upload (including optional per-track cover images)
  • -
  • Optional custom watermark assets (text, PNG logo, or font files)
  • -
  • Generated video files prior to or during YouTube upload
  • -
  • - Per-video metadata you provide (title, artist, description, tags, privacy, category, - resolution, layout, watermark settings, Made for Kids / embedding / license flags, etc.) -
  • -
  • - Embedded audio tag metadata (for example ID3 title/artist/album) when we read it from - uploaded MP3 files to help prefill fields -
  • -
  • Playlist titles and IDs when you create or attach YouTube playlists through the Service
  • -
- -

3.3 Usage, API, and technical data

-
    -
  • - Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset - dates, and job processing status -
  • -
  • - Quota reset / extension and API rate-limit extension request history (Pro) when you submit - a request -
  • -
  • - API key material for Pro users: we store a cryptographic hash and a short non-secret - prefix; the full key is shown once at creation and is not stored in plaintext -
  • -
  • IP address, browser type, device information, and request logs
  • -
  • Error reports and operational diagnostics
  • -
  • - Website and product analytics events collected via Matomo (self-hosted at - analytics.atakanozban.com) to understand traffic and improve the Service — see section - 10 -
  • -
- -

3.4 Payment data

-

- If you purchase a Pro subscription or Free-plan extra credits, payment processing is handled - by Stripe. We do not store full payment card details on our servers. We may receive and store - billing status, Stripe customer and subscription identifiers, Checkout session or payment - references, purchase amounts, and credit pack size for fulfilled top-ups. -

- -

4. Why we process your data

-
    -
  • - Contract performance: to provide video encoding, metadata handling, - playlist actions, API access, and YouTube upload features you request -
  • -
  • - Legitimate interests: to secure our service, prevent abuse, improve - reliability, and enforce our terms -
  • -
  • - Legal obligations: where required by tax, accounting, or regulatory law -
  • -
  • - Consent: where you have given explicit consent, such as optional - marketing communications if offered -
  • -
- -

5. Third-party services

-

We use trusted third parties to operate Songs2VID, including:

-
    -
  • - Google / YouTube: authentication and video uploads via Google OAuth and - the YouTube Data API. Your use of Google and YouTube is also subject to{" "} - - Google's Privacy Policy - - ,{" "} - - YouTube Terms of Service - - , and related Google API terms -
  • -
  • - Hosting and infrastructure providers: servers, databases, queues, and - storage -
  • -
  • - Stripe: Pro subscriptions, Free-plan credit top-ups, and related billing - webhooks ( - - Stripe Privacy Policy - - ) -
  • -
  • - Matomo (self-hosted analytics): privacy-friendly analytics we operate at - analytics.atakanozban.com to measure visits and improve Songs2VID. Analytics data stays on - infrastructure we control; we do not sell it to advertising networks. -
  • -
-

- These providers process data only as necessary to deliver their services and under - appropriate contractual safeguards where required. -

- -

6. Google / YouTube user data (Limited Use)

-

- Songs2VID's use and transfer to any other app of information received from Google APIs - will adhere to the{" "} - - Google API Services User Data Policy - - , including the Limited Use requirements. -

-

- We request Google OAuth access (including the YouTube Data API scope needed to upload videos - and manage playlists on your connected channel) solely to provide prominent, user-facing - features of Songs2VID: signing you in, connecting your channel, encoding your media, uploading - videos you create, and creating or listing playlists you request. We do not use Google user - data for advertising, credit scoring, or unrelated profiling. -

-

- We do not sell, share, transfer, or disclose Google user data obtained via Google OAuth / - YouTube APIs to third parties, except as needed to operate the Service infrastructure under - our control or when required by law. Google / YouTube themselves process data when we call - their APIs on your behalf to perform actions you initiate. -

-

- You can revoke Songs2VID's access to your Google account at any time in{" "} - - Google Account → Security → Third-party access - - . After revocation (or when tokens can no longer be refreshed), we will stop using those - credentials and delete or invalidate stored YouTube OAuth tokens and related connection data - associated with that consent, subject to short-term backup or security logs and any legal - retention duties. -

- -

7. Data retention and account deletion

-
    -
  • - Uploaded source files and generated outputs are retained only as long as needed to complete - your jobs (typically removed after successful processing/upload or when no longer required) -
  • -
  • Account data is kept while your account remains active
  • -
  • Billing records may be retained as required by law
  • -
  • Logs are retained for a limited period for security and troubleshooting
  • -
  • - API key hashes are removed when you revoke the key, downgrade from Pro (where applicable), - or delete your account -
  • -
-

- You may delete your account from Dashboard → Settings (account deletion - control) or by emailing{" "} - {LEGAL_OPERATOR.email}. Deletion removes - account and connection data from active systems subject to legal retention obligations - (for example certain billing records). Cancelling a subscription does not by itself delete - your account. -

- -

8. Self-hosted deployments

-

- If you deploy Songs2VID on your own infrastructure, you are the data controller for data - processed on your instance. This Privacy Policy applies to the hosted cloud service - operated by us, not to independent self-hosted installations unless we provide managed - hosting for you under contract. -

- -

9. Your rights

-

Depending on your location, you may have the right to:

-
    -
  • Access the personal data we hold about you
  • -
  • Request correction or deletion
  • -
  • Restrict or object to certain processing
  • -
  • Data portability
  • -
  • Withdraw consent where processing is consent-based
  • -
  • - Lodge a complaint with a supervisory authority (in Hungary, the Nemzeti Adatvédelmi és - Információszabadság Hatóság — NAIH) -
  • -
-

- To exercise these rights, contact{" "} - {LEGAL_OPERATOR.email}, or use in-product - account deletion where available. You may also revoke Google access as described in section - 6. -

- -

10. Cookies, analytics, and local storage

-

- We use essential cookies and similar technologies for authentication, session management, - and security. -

-

- We also use Matomo, a self-hosted analytics tool on{" "} - analytics.atakanozban.com, on our website (including the landing page and - dashboard) and documentation site. Matomo helps us understand how people use Songs2VID so we - can improve functionality, reliability, and content. Typical data includes pages viewed, - approximate location derived from IP, device/browser type, referring site, and interaction - events. We configure Matomo for our domains under *.songs2vid.com. -

-

- We collect this analytics data to improve the Service, not to sell personal profiles to - advertisers. The legal basis is our legitimate interest in operating and improving - Songs2VID (and consent where required by local law). You can block analytics with browser - settings, extensions, or Do Not Track / equivalent controls where supported. For rights - requests related to analytics data, contact{" "} - {LEGAL_OPERATOR.email}. -

- -

11. Security

-

- We implement appropriate technical and organizational measures to protect your data, - including encryption in transit, encrypted storage of YouTube OAuth tokens at rest, access - controls, and isolated processing environments. No method of transmission or storage is - 100% secure. -

- -

12. International transfers

-

- If data is transferred outside your country, we ensure appropriate safeguards such as - standard contractual clauses or equivalent mechanisms where required by law. Google, Stripe, - and infrastructure providers may process data in other countries as described in their - policies. -

- -

13. Children

-

- Songs2VID is not directed at children under 16. We do not knowingly collect personal data - from children. If you believe a child has provided us data, please contact us. -

- -

14. Changes to this policy

-

- We may update this Privacy Policy from time to time. Material changes will be posted on - this page with an updated effective date. If we change how we use Google user data, we will - update this policy and, where required, notify you or obtain renewed consent. -

- -

15. Contact

-

- Questions about this Privacy Policy or our privacy practices:{" "} - {LEGAL_OPERATOR.email} + Google and YouTube process account and upload data according to their own policies. Contact + the operator of this instance for data access or deletion requests.

); diff --git a/app/refund/page.tsx b/app/refund/page.tsx deleted file mode 100644 index 8b15c4a..0000000 --- a/app/refund/page.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { LegalPageLayout } from "@/components/LegalPageLayout"; -import { LEGAL_OPERATOR } from "@/lib/legal/constants"; - -export const metadata: Metadata = { - title: "Refund Policy", - description: "Refund and cancellation policy for Songs2VID paid plans.", - alternates: { canonical: "https://songs2vid.com/refund" }, -}; - -export default function RefundPage() { - return ( - -

1. Overview

-

- This Refund Policy explains how refunds and cancellations work for paid Songs2VID plans and - credit purchases. The free monthly Bedroom Producer allocation does not involve payment and - is not subject to refunds; optional Free-plan extra credit purchases are covered in section - 2. -

- -

2. Free plan and extra credits

-

- The Bedroom Producer (Free) monthly allocation is provided at no charge. Free users may - optionally purchase extra video credits (currently €0.25 each, 1–15 per checkout, Free extras - balance capped at 15). Once extra credits are granted to your account, those one-time - purchases are generally non-refundable, except where mandatory consumer law - requires otherwise. Credits trimmed under the account accumulation cap (see our{" "} - Terms of Service) are not refundable. -

- -

3. Pro subscriptions

-

3.1 Billing cycle

-

- Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout. - Your subscription renews automatically until cancelled. -

- -

3.2 14-day refund window

-

- If you are a new Pro subscriber, you may request a full refund within 14 days of - your initial purchase, provided you have not substantially consumed paid entitlements - (for example, a large portion of your monthly video quota or premium-only features). -

- -

3.3 After the refund window

-

- After 14 days, subscription fees are generally non-refundable for the current billing - period. You may cancel at any time to prevent future renewals. Access typically continues - until the end of the paid period. -

- -

3.4 Cancellation

-

- You can cancel your subscription through your account billing settings (choose{" "} - cancel immediately or at the end of the billing period) or - via the Stripe customer portal / by contacting{" "} - {LEGAL_OPERATOR.email}. Cancelling at period - end keeps Pro access until the paid period finishes and stops future renewals. Cancelling - immediately ends Pro access and moves you to the Free plan right away (including loss of Pro - API access). Cancellation does not automatically delete your account; use{" "} - Dashboard → Settings or contact us if you want permanent account deletion. -

- -

4. Enterprise and custom agreements

-

- Enterprise, managed cloud, and paid self-hosted setup fees are governed by the individual - quote or contract signed with us. Refund terms for those services are specified in your - agreement. Contact{" "} - {LEGAL_OPERATOR.salesEmail} for - contract-related billing questions. -

- -

5. Non-refundable situations

-

Refunds are generally not provided when:

-
    -
  • The refund request is made outside the applicable refund window
  • -
  • The account was terminated for violation of our Terms of Service
  • -
  • The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)
  • -
  • You simply changed your mind after substantial use of paid quota or features
  • -
  • Purchased Free-plan extra credits have already been granted to your account
  • -
  • Credits expired or were trimmed under the 30-credit accumulation cap
  • -
  • One-time setup or license fees after delivery of agreed setup work, unless required by law or contract
  • -
- -

6. Chargebacks

-

- If you believe a charge is incorrect, please contact us before initiating a chargeback so - we can resolve the issue promptly. Unjustified chargebacks may result in account suspension. -

- -

7. How to request a refund

-

Email us at {LEGAL_OPERATOR.email} with:

-
    -
  • Your account email address
  • -
  • Date of purchase and invoice or transaction reference if available
  • -
  • Reason for the refund request
  • -
-

We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.

- -

8. Consumer rights

-

- Nothing in this policy limits mandatory statutory rights you may have as a consumer under - applicable law, including withdrawal rights where required by EU or local consumer - protection regulations. -

- -

9. Changes

-

- We may update this Refund Policy from time to time. The version published on this page - applies to purchases made after the effective date shown at the top. -

-
- ); -} diff --git a/app/terms/page.tsx b/app/terms/page.tsx index 281b4f0..9767c77 100644 --- a/app/terms/page.tsx +++ b/app/terms/page.tsx @@ -1,261 +1,15 @@ -import type { Metadata } from "next"; -import Link from "next/link"; import { LegalPageLayout } from "@/components/LegalPageLayout"; -import { LEGAL_OPERATOR } from "@/lib/legal/constants"; - -export const metadata: Metadata = { - title: "Terms of Service", - description: "Terms and conditions for using the Songs2VID hosted service.", - alternates: { canonical: "https://songs2vid.com/terms" }, -}; export default function TermsPage() { return ( - -

1. Agreement

+

- These Terms of Service ("Terms") govern your access to and use of the Songs2VID - website and hosted cloud service (the "Service") operated by{" "} - {LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using - the Service, you agree to these Terms. + Songs2VID is provided as open-source, self-hosted software without warranty. The operator of + each instance is responsible for availability, configuration, and user access.

- If you do not agree, do not use the Service. If you self-host the open-source software on - your own infrastructure without using our hosted Service, these Terms apply only to the - extent you use our website, support channels, or paid services we provide. -

- -

2. The Service

-

- Songs2VID converts user-provided images and audio files into videos and can upload them to - YouTube using your connected Google/YouTube account. Features, limits, and availability - depend on your plan. -

-
    -
  • - Bedroom Producer (Free): 10 video credits per calendar month, 720p, MP3, - optional Songs2VID watermark (bottom-right), one static cover image per batch; may purchase - limited extra credits as described in Clause 8.2 -
  • -
  • - Independent Artist (Pro): 50 video credits per month, higher resolution and - batch limits, API access, custom watermark/logo/typography, blurred art-track layouts with - position controls, unique image per - track in bulk uploads, and other features as described on our pricing page (€5/month - unless otherwise stated at checkout) -
  • -
  • - Enterprise (Record Label / Studio): custom terms as agreed in writing -
  • -
-

- We may modify features, limits, or pricing with reasonable notice where required. The - open-source software is provided separately under its applicable open-source license. -

- -

3. Eligibility and accounts

-
    -
  • You must be at least 16 years old or the age required in your jurisdiction
  • -
  • You must have a valid Google account and authorized access to the YouTube channel you connect
  • -
  • You are responsible for maintaining the security of your account and OAuth connection
  • -
  • You must provide accurate information and promptly update it if it changes
  • -
- -

4. Your content and responsibilities

-

You retain ownership of content you upload. You grant us a limited license to host, process, encode, transmit, and upload your content solely to provide the Service.

-

You represent and warrant that:

-
    -
  • You own or have all necessary rights to the content you upload
  • -
  • Your content and use of the Service comply with applicable law and YouTube policies
  • -
  • Your content does not infringe third-party rights or contain unlawful material
  • -
  • You have configured metadata (including "Made for Kids" and privacy settings) accurately
  • -
-

- You are solely responsible for content published to your YouTube channel through the - Service. -

- -

5. Acceptable use

-

You agree not to:

-
    -
  • Use the Service for unlawful, harmful, or abusive purposes
  • -
  • Upload malware, attempt unauthorized access, or interfere with the Service
  • -
  • Circumvent quotas, plan limits, or technical restrictions
  • -
  • Resell or commercially exploit the hosted Service without authorization
  • -
  • Use the Service in a way that violates Google, YouTube, or third-party terms
  • -
-

We may suspend or terminate access for violations or risks to the Service or other users.

- -

6. YouTube and third-party services

-

- The Service integrates with Google OAuth and the YouTube Data API to sign you in, connect - your channel, upload videos you create, and create or list playlists you request. Your use - of those services is also subject to{" "} - - Google Terms of Service - - ,{" "} - - YouTube Terms of Service - - ,{" "} - - YouTube API Services Terms - - , and{" "} - - Google's Privacy Policy - - . We are not responsible for changes, outages, quota limits, or enforcement actions taken by - Google or YouTube. How we handle Google user data is described in our{" "} - Privacy Policy. -

- -

7. Open-source software

-

- Portions of Songs2VID are available as open-source software. Self-hosting is permitted under - the applicable open-source license. The hosted Service, enterprise features, managed - infrastructure, and certain premium capabilities may require a separate commercial license - or subscription. -

- -

8. Subscription, Billing & Credits

-

- Paid plans and credit top-ups are billed according to the pricing displayed at the time of - purchase. Taxes may apply. Payments are processed by Stripe. Subscriptions renew - automatically unless cancelled in accordance with our{" "} - Refund Policy. Failure to pay may result in downgrade or - suspension. -

-

- Credit usage order: when you create video jobs, we deduct from your current - monthly allocation first, then from any purchased extra credits. -

- -

8.1 Pro plan quota and rate-limit extensions

-

- Independent Artist (Pro) subscribers receive 50 video credits per - subscription month. Unused credits from prior cycles may roll over into the next cycle - subject to the accumulation cap in Clause 8.3. Renewal and rollover occur on successful - Stripe subscription renewal (typically aligned with your billing period). -

-

- Pro subscribers may request a manual video-quota reset or temporary extension, and/or an API - rate-limit extension, via account settings or by emailing{" "} - - {LEGAL_OPERATOR.quotaRequestEmail} - - . Each Pro account is entitled to up to{" "} - five (5) video-quota reset or extension requests per calendar year and up - to five (5) API rate-limit extension requests per calendar year. We review - requests in good faith and may decline requests that are abusive, repetitive without cause, - or inconsistent with fair use. Approved requests do not increase those annual limits. -

- -

8.2 Free plan extra credits

-

- Bedroom Producer (Free) accounts receive 10 video credits per calendar - month. Free users may purchase additional never-expiring extra credits at the price shown at - checkout (currently €0.25 per credit), in packs of{" "} - 1 to 15 credits per purchase. On the Free plan, the extras balance may not - exceed 15 credits. After monthly and extra credits are exhausted, further - uploads require upgrading to Pro (or waiting for the next monthly reset). Extra credits are - available only on the Free plan purchase flow; Pro includes its monthly allocation and does - not sell the same Free top-up packs. -

- -

8.3 Credit rollover & accumulation cap

-

- Unused credits from previous cycles may roll over, but the total accumulated balance on any - account (remaining monthly allocation + extra credits) shall strictly not exceed{" "} - 30 credits at any given time. Upon renewal (or when applying a new monthly - allocation), any excess above this 30-credit threshold is automatically trimmed and expires - permanently, without entitlement to refund or compensation. -

- -

8.4 Pro API access

-

- Independent Artist (Pro) subscribers may generate an API key in account settings to upload - files and create batch video jobs programmatically. API access requires an active Pro - subscription, a connected YouTube account, and compliance with the same quota, file-type, and - resolution limits as the web interface. API keys are personal, must be kept confidential, and - may be revoked by you or by us if misused. We may apply rate limits and suspend API access - for abuse, security incidents, or plan downgrades. If your subscription ends or you are - moved to the Free plan, API keys and Pro-only API access are revoked or disabled. -

- -

9. Availability and support

-

- We strive for high availability but do not guarantee uninterrupted access. Maintenance, - updates, and outages may occur. Support levels depend on your plan. Self-hosted DIY - deployments without a paid setup are community-supported unless otherwise agreed in - writing. -

- -

10. Disclaimer of warranties

-

- THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" TO THE MAXIMUM EXTENT - PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING, - UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED. -

- -

11. Limitation of liability

-

- TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, - SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE, DATA, OR - GOODWILL. OUR TOTAL LIABILITY FOR ANY CLAIM ARISING OUT OF THESE TERMS OR THE SERVICE IS - LIMITED TO THE AMOUNT YOU PAID US IN THE TWELVE (12) MONTHS BEFORE THE EVENT GIVING RISE TO - THE CLAIM, OR EUR 100 IF YOU USE THE FREE PLAN ONLY. -

-

- Some jurisdictions do not allow certain limitations, so some of the above may not apply to - you. -

- -

12. Indemnification

-

- You agree to indemnify and hold us harmless from claims arising out of your content, your - use of the Service, or your violation of these Terms or applicable law. -

- -

13. Termination and account deletion

-

- You may stop using the Service at any time. You may cancel a paid subscription (see our{" "} - Refund Policy) without deleting your account. To permanently - delete your account and associated active data, use the account deletion control in{" "} - Dashboard → Settings or contact{" "} - {LEGAL_OPERATOR.email}. We may suspend or - terminate your access if you breach these Terms, create risk or legal exposure, or where - required by law. Upon termination or deletion, your right to use the hosted Service ends. - Provisions that by nature should survive will survive. Deletion is subject to legal - retention obligations described in our Privacy Policy. -

- -

14. Governing law

-

- These Terms are governed by the laws of Hungary, excluding conflict - of law rules. Courts in Hungary shall have exclusive jurisdiction unless - mandatory consumer protection laws in your country provide otherwise. -

- -

15. Changes

-

- We may update these Terms from time to time. Continued use after changes become effective - constitutes acceptance of the revised Terms, where permitted by law. -

- -

16. Contact

-

- Questions about these Terms:{" "} - {LEGAL_OPERATOR.email} + You are responsible for the media you process and upload, including compliance with + copyright law and YouTube's terms.

); diff --git a/assets/bg-video - Repaired.mp4 b/assets/bg-video - Repaired.mp4 deleted file mode 100644 index 4d39ad2..0000000 Binary files a/assets/bg-video - Repaired.mp4 and /dev/null differ diff --git a/assets/bg-video.mp4 b/assets/bg-video.mp4 deleted file mode 100644 index 4d39ad2..0000000 Binary files a/assets/bg-video.mp4 and /dev/null differ diff --git a/components/AdminBadgePanel.tsx b/components/AdminBadgePanel.tsx deleted file mode 100644 index 4cfbbda..0000000 --- a/components/AdminBadgePanel.tsx +++ /dev/null @@ -1,349 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useState, type FormEvent } from "react"; - -const STORAGE_KEY = "s2vid_admin_api_key"; - -export type AdminBadge = { - id: string; - name: string; - embedHtml: string | null; - imageUrl: string | null; - linkUrl: string | null; - isActive: boolean; - sortOrder: number; - createdAt: string; - updatedAt: string; -}; - -function authHeaders(key: string): HeadersInit { - return { - Authorization: `Bearer ${key}`, - "Content-Type": "application/json", - }; -} - -export function AdminBadgePanel() { - const [apiKey, setApiKey] = useState(""); - const [unlocked, setUnlocked] = useState(false); - const [badges, setBadges] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const [name, setName] = useState(""); - const [embedHtml, setEmbedHtml] = useState(""); - const [imageUrl, setImageUrl] = useState(""); - const [linkUrl, setLinkUrl] = useState(""); - const [saving, setSaving] = useState(false); - - useEffect(() => { - const stored = sessionStorage.getItem(STORAGE_KEY); - if (stored) { - setApiKey(stored); - setUnlocked(true); - } - }, []); - - const loadBadges = useCallback(async (key: string) => { - setLoading(true); - setError(null); - try { - const res = await fetch("/api/admin/badges", { - headers: authHeaders(key), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to load badges"); - setBadges(data.badges); - setUnlocked(true); - sessionStorage.setItem(STORAGE_KEY, key); - } catch (err) { - setUnlocked(false); - setBadges([]); - setError(err instanceof Error ? err.message : "Failed to load badges"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (unlocked && apiKey) { - void loadBadges(apiKey); - } - }, [unlocked, apiKey, loadBadges]); - - async function unlock(e: FormEvent) { - e.preventDefault(); - const key = apiKey.trim(); - if (!key) { - setError("Enter ADMIN_API_KEY"); - return; - } - await loadBadges(key); - } - - function lock() { - sessionStorage.removeItem(STORAGE_KEY); - setApiKey(""); - setUnlocked(false); - setBadges([]); - setSuccess(null); - setError(null); - } - - async function createBadge(e: FormEvent) { - e.preventDefault(); - setSaving(true); - setError(null); - setSuccess(null); - try { - const res = await fetch("/api/admin/badges", { - method: "POST", - headers: authHeaders(apiKey), - body: JSON.stringify({ - name: name.trim(), - embedHtml: embedHtml.trim() || null, - imageUrl: imageUrl.trim() || null, - linkUrl: linkUrl.trim() || null, - isActive: true, - }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to create badge"); - setName(""); - setEmbedHtml(""); - setImageUrl(""); - setLinkUrl(""); - setSuccess(`Added “${data.badge.name}”`); - await loadBadges(apiKey); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create badge"); - } finally { - setSaving(false); - } - } - - async function toggleActive(badge: AdminBadge) { - setError(null); - setSuccess(null); - try { - const res = await fetch(`/api/admin/badges/${badge.id}`, { - method: "PATCH", - headers: authHeaders(apiKey), - body: JSON.stringify({ isActive: !badge.isActive }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to update badge"); - setSuccess( - data.badge.isActive - ? `“${data.badge.name}” is now active` - : `“${data.badge.name}” is now inactive`, - ); - await loadBadges(apiKey); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update badge"); - } - } - - async function removeBadge(badge: AdminBadge) { - if (!confirm(`Delete badge “${badge.name}”?`)) return; - setError(null); - setSuccess(null); - try { - const res = await fetch(`/api/admin/badges/${badge.id}`, { - method: "DELETE", - headers: authHeaders(apiKey), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || "Failed to delete badge"); - setSuccess(`Deleted “${badge.name}”`); - await loadBadges(apiKey); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to delete badge"); - } - } - - if (!unlocked) { - return ( -
-

Admin unlock

-

- Enter ADMIN_API_KEY to manage footer badges. - The key stays in this browser tab only (sessionStorage). -

-
- setApiKey(e.target.value)} - placeholder="ADMIN_API_KEY" - className="input-field flex-1" - autoComplete="off" - /> - -
- {error ?

{error}

: null} -
- ); - } - - return ( -
-
-

- Authenticated with admin API key. Changes apply to the live footer marquee. -

- -
- - {error ?

{error}

: null} - {success ?

{success}

: null} - -
-

Add badge

-

- Paste a Product Hunt / LaunchBuff embed (HTML), or set image + link URLs. -

-
-
- - setName(e.target.value)} - className="input-field" - placeholder="Product Hunt" - required - /> -
-
- -