Strip Songs2VID to a payment-free self-hosted OSS core.

Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
This commit is contained in:
Atakan Doğan Özban
2026-08-03 06:52:00 +02:00
parent 506d56fa92
commit 6476bb42a4
108 changed files with 397 additions and 8061 deletions
+9 -41
View File
@@ -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" REDIS_URL="redis://localhost:6380"
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET="replace-with-a-long-random-secret" 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" # 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_ID="your-google-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="your-google-client-secret" GOOGLE_CLIENT_SECRET="your-google-client-secret"
UPLOAD_DIR="./uploads" UPLOAD_DIR="./uploads"
# Open-source / self-hosted: unlimited video quota, API, and Pro features
# S2VID_EDITION="selfhosted" NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com"
# 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_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues" NEXT_PUBLIC_GITEA_ISSUES_URL="https://git.atakanozban.com/Songs2VID/songs2vid/issues"
NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID" NEXT_PUBLIC_GITEA_URL="https://git.atakanozban.com/Songs2VID"
NEXT_PUBLIC_DOCKER_HUB_URL="https://hub.docker.com/r/atakanozban/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" # Optional: override the bundled ffmpeg-static binary.
# Production: NEXT_PUBLIC_DOCS_URL="https://docs.songs2vid.com" # FFMPEG_PATH="C:/path/to/ffmpeg.exe"
# Stripe billing (Pro €5/mo + Free 115 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
+1 -1
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1 # 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 FROM node:22-bookworm-slim AS deps
WORKDIR /app WORKDIR /app
+22 -99
View File
@@ -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) 1. Copy `.env.example` to `.env` and set `NEXTAUTH_SECRET`, `GOOGLE_CLIENT_ID`, and
- PostgreSQL + Prisma `GOOGLE_CLIENT_SECRET`.
- Redis + BullMQ 2. In Google Cloud Console, enable YouTube Data API v3 and add
- NextAuth (Google OAuth 2.0 with YouTube scopes) `http://localhost:3000/api/auth/callback/google` as an OAuth redirect URI.
- FFmpeg for video encoding 3. Start the stack:
- 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):
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
Open [http://localhost:3000](http://localhost:3000). Open http://localhost:3000.
Add Google OAuth redirect URI: `http://localhost:3000/api/auth/callback/google` (or your public URL).
## Local development ## Local development
1. Copy environment variables:
```bash
cp .env.example .env
```
2. Start Postgres and Redis only:
```bash ```bash
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
```
3. Install dependencies and push the schema:
```bash
npm install npm install
npm run db:push npm run db:migrate
```
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 dev:all npm run dev:all
``` ```
- App: http://localhost:3000 The web app runs on http://localhost:3000. `dev:all` starts both the Next.js app and BullMQ worker.
- Docs: http://localhost:3001
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. Documentation, including the REST API reference, is at
[docs.songs2vid.com](https://docs.songs2vid.com).
## 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
-26
View File
@@ -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 (
<main className="min-h-screen bg-surface-dark text-gray-100">
<div className="mx-auto max-w-3xl px-6 py-10">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Admin
</p>
<h1 className="mt-1 text-2xl font-bold text-white">Footer badges</h1>
<p className="mt-2 text-sm text-gray-400">
Manage the infinite badge marquee on the public site footer. Only active badges are
shown.
</p>
</header>
<AdminBadgePanel />
</div>
</main>
);
}
-10
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys"; import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
async function requireApiKeyAccess() { async function requireApiKeyAccess() {
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
if (!user) { if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null }; 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 }; return { error: null, user };
} }
@@ -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 });
}
}
-8
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
export async function GET() { export async function GET() {
@@ -8,13 +7,6 @@ export async function GET() {
if (!user) { if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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); const status = await getApiRateLimitStatus(user.id);
return NextResponse.json(status); return NextResponse.json(status);
} }
-64
View File
@@ -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 },
);
}
}
@@ -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",
});
}
}
-192
View File
@@ -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 115 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 });
}
}
@@ -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 });
}
}
-87
View File
@@ -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 });
}
-95
View File
@@ -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<string, unknown>;
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 });
}
-48
View File
@@ -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<string, unknown>;
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 });
}
@@ -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 });
}
}
-69
View File
@@ -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,
},
});
}
-24
View File
@@ -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",
},
});
}
-95
View File
@@ -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);
}
+1 -6
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob } from "@/lib/jobs/create-job"; import { createVideoJob } from "@/lib/jobs/create-job";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/session"; import { requireAuth } from "@/lib/session";
@@ -17,12 +16,8 @@ export async function POST(req: NextRequest) {
const job = await createVideoJob(user, body); const job = await createVideoJob(user, body);
return NextResponse.json({ jobId: job.id }); return NextResponse.json({ jobId: job.id });
} catch (err) { } 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 message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400; return NextResponse.json({ error: message }, { status: 400 });
return NextResponse.json({ error: message }, { status });
} }
} }
-255
View File
@@ -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 });
}
+1 -5
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job"; import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
@@ -28,14 +27,11 @@ export async function POST(req: NextRequest) {
} }
try { try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, { const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
sessionKey, sessionKey,
}); });
return NextResponse.json(result); return NextResponse.json(result);
} catch (err) { } catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed"; const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 }); return NextResponse.json({ error: message }, { status: 400 });
} }
+2 -2
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth"; import { requireApiUser } from "@/lib/api-auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
const { error, user } = await requirePaidApiUser(_req); const { error, user } = await requireApiUser(_req);
if (error || !user) return error!; if (error || !user) return error!;
const { id } = await params; const { id } = await params;
+7 -18
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client"; import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth"; import { requireApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job"; import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import { import {
applyCreatePlaylistToItems, applyCreatePlaylistToItems,
@@ -41,7 +40,7 @@ function defaultMetadata(title: string): ItemMetadata {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { error, user } = await requirePaidApiUser(req); const { error, user } = await requireApiUser(req);
if (error || !user) return error!; if (error || !user) return error!;
const formData = await req.formData(); 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) { if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json( 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 }, { status: 400 },
); );
} }
@@ -98,10 +97,10 @@ export async function POST(req: NextRequest) {
} }
const sessionKey = Date.now().toString(); 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) => 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) => { const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
@@ -142,18 +141,8 @@ export async function POST(req: NextRequest) {
}); });
} catch (err) { } catch (err) {
console.error("Batch upload failed:", 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 message = err instanceof Error ? err.message : "Batch upload failed";
const status = const status = message.includes("Batch limit exceeded") ? 400 : 500;
message.includes("Batch limit exceeded")
? 400
: message.includes("Quota exceeded") ||
message.includes("Not enough credits") ||
message.includes("Payment Required")
? 402
: 500;
return NextResponse.json({ error: message }, { status }); return NextResponse.json({ error: message }, { status });
} }
} }
+4 -14
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth"; import { requireApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob } from "@/lib/jobs/create-job"; import { createVideoJob } from "@/lib/jobs/create-job";
import { import {
applyCreatePlaylistToItems, applyCreatePlaylistToItems,
@@ -10,7 +9,7 @@ import { prisma } from "@/lib/db";
import type { CreateJobPayload } from "@/lib/types"; import type { CreateJobPayload } from "@/lib/types";
export async function POST(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!; if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown }; const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
@@ -47,22 +46,13 @@ export async function POST(req: NextRequest) {
: null, : null,
}); });
} catch (err) { } 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 message = err instanceof Error ? err.message : "Failed to create job";
const status = return NextResponse.json({ error: message }, { status: 400 });
message.includes("Quota exceeded") ||
message.includes("Not enough credits") ||
message.includes("Payment Required")
? 402
: 400;
return NextResponse.json({ error: message }, { status });
} }
} }
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req); const { error, user } = await requireApiUser(req);
if (error || !user) return error!; if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100); const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
+3 -3
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; 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 { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload"; import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req); const { error, user } = await requireApiUser(req);
if (error || !user) return error!; if (error || !user) return error!;
try { try {
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
} }
export async function POST(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!; if (error || !user) return error!;
try { try {
+5 -5
View File
@@ -6,8 +6,8 @@ export async function GET() {
name: "Songs2VID API", name: "Songs2VID API",
version: "1.0", version: "1.0",
authentication: "Authorization: Bearer <api_key>", authentication: "Authorization: Bearer <api_key>",
requirements: ["Pro subscription", "YouTube account connected"], requirements: ["YouTube account connected"],
rateLimit: "60 requests per minute per account (plus any approved bonus)", rateLimit: "100,000 requests per minute per account",
guidance: { guidance: {
recommended: recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths", "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", method: "POST",
path: "/api/v1/upload", path: "/api/v1/upload",
description: 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)", body: "multipart/form-data: file, type (image|audio|logo|font)",
}, },
{ {
method: "POST", method: "POST",
path: "/api/v1/jobs", path: "/api/v1/jobs",
description: 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 }] }", body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
}, },
{ {
@@ -39,7 +39,7 @@ export async function GET() {
{ {
method: "GET", method: "GET",
path: "/api/v1/playlists", path: "/api/v1/playlists",
description: "List YouTube playlists for the authenticated Pro account", description: "List YouTube playlists for the authenticated account",
}, },
{ {
method: "POST", method: "POST",
+3 -7
View File
@@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth"; import { requireApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job"; import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
export const maxDuration = 120; export const maxDuration = 120;
export async function POST(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!; if (error || !user) return error!;
const formData = await req.formData(); const formData = await req.formData();
@@ -25,12 +24,9 @@ export async function POST(req: NextRequest) {
} }
try { 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); return NextResponse.json(result);
} catch (err) { } catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed"; const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 }); return NextResponse.json({ error: message }, { status: 400 });
} }
-10
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session"; import { getSessionUser } from "@/lib/session";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist"; import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload"; import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
@@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() {
if (!user) { if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null }; 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) { if (!user.youtubeConnection) {
return { return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }), error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
+15 -163
View File
@@ -4,23 +4,10 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions"; import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
import { CreditPurchasePanel } from "@/components/CreditPurchasePanel";
import { PlanBillingActions } from "@/components/PlanBillingActions";
import { DashboardShell } from "@/components/DashboardShell"; import { DashboardShell } from "@/components/DashboardShell";
import { UpgradeProButton } from "@/components/UpgradeProButton";
import { getUserApiKeyStatus } from "@/lib/api-keys"; import { getUserApiKeyStatus } from "@/lib/api-keys";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit"; import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { ApiKeySettings } from "@/components/ApiKeySettings"; 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 }) { function InfoRow({ label, value }: { label: string; value: string }) {
return ( return (
@@ -41,22 +28,10 @@ export default async function SettingsPage() {
}); });
if (!user) redirect("/"); if (!user) redirect("/");
const quota = await getQuotaInfo(user.id); const [apiKeyStatus, apiRateLimit] = await Promise.all([
const selfHosted = isSelfHostedEdition(); getUserApiKeyStatus(user.id),
const proFeatures = hasProFeatures(user.plan); getApiRateLimitStatus(user.id),
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 youtube = user.youtubeConnection; const youtube = user.youtubeConnection;
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null; const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
@@ -64,19 +39,18 @@ export default async function SettingsPage() {
<DashboardShell channelTitle={youtube?.channelTitle}> <DashboardShell channelTitle={youtube?.channelTitle}>
<div className="mx-auto max-w-4xl px-6 py-8"> <div className="mx-auto max-w-4xl px-6 py-8">
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1> <h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
<div className="space-y-6"> <div className="space-y-6">
<section className="rounded-lg border border-gray-700 bg-surface-light p-6"> <section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Account information</h2> <h2 className="mb-4 text-lg font-semibold text-white">Account</h2>
<dl className="space-y-3 text-sm"> <dl className="space-y-3 text-sm">
<InfoRow label="Name" value={user.name || "Not set"} /> <InfoRow label="Name" value={user.name || "Not set"} />
<InfoRow label="Email" value={user.email} /> <InfoRow label="Email" value={user.email} />
<InfoRow label="Videos created" value={String(user.createdVideoCount)} />
</dl> </dl>
</section>
<div className="mt-6 border-t border-gray-800 pt-6"> <section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500"> <h2 className="mb-4 text-lg font-semibold text-white">YouTube</h2>
YouTube
</h3>
{youtube ? ( {youtube ? (
<dl className="space-y-3 text-sm"> <dl className="space-y-3 text-sm">
<InfoRow label="Channel name" value={youtube.channelTitle} /> <InfoRow label="Channel name" value={youtube.channelTitle} />
@@ -85,153 +59,31 @@ export default async function SettingsPage() {
) : ( ) : (
<p className="text-sm text-red-400">YouTube account not connected.</p> <p className="text-sm text-red-400">YouTube account not connected.</p>
)} )}
{channelUrl && ( {channelUrl && (
<a <a
href={channelUrl} href={channelUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover" className="mt-4 inline-flex rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
> >
Go to your channel Go to your channel
</a> </a>
)} )}
<p className="mt-4 text-sm text-gray-400"> <p className="mt-4 text-sm text-gray-400">
To refresh your YouTube permissions,{" "} To refresh YouTube permissions,{" "}
<Link href="/" className="text-accent hover:underline"> <Link href="/" className="text-accent hover:underline">sign out and sign in again</Link>.
sign out and sign in again
</Link>
.
</p> </p>
</div>
</section> </section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6"> <section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Plan &amp; billing</h2> <h2 className="mb-4 text-lg font-semibold text-white">API key</h2>
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Video quota remaining
</p>
<p className="mt-1 text-2xl font-semibold text-white">
{quota.totalAvailable}
<span className="text-base font-normal text-gray-400">
{" "}
of {quota.limit + quota.extraCredits} videos
</span>
</p>
</div>
<p className="text-sm text-gray-400">
{quota.used} monthly used
{quota.extraCredits > 0 ? ` · ${quota.extraCredits} extras` : ""}
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
</p>
</div>
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
<div
className={`h-full rounded ${
quota.totalAvailable <= 0
? "bg-red-500"
: quota.totalAvailable / Math.max(1, quota.limit + quota.extraCredits) <= 0.2
? "bg-amber-500"
: "bg-accent"
}`}
style={{
width: `${Math.min(
100,
Math.round(
((quota.limit + quota.extraCredits - quota.totalAvailable) /
Math.max(1, quota.limit + quota.extraCredits)) *
100,
),
)}%`,
}}
/>
</div>
<p className="mt-2 text-xs text-gray-500">
{`Includes monthly + purchased extras · monthly resets on ${quota.resetsIn}`}
</p>
</div>
<dl className="space-y-3 text-sm">
<InfoRow label="Current plan" value={planLabel} />
<InfoRow
label="Monthly credits"
value={`${quota.monthlyCredits} / cycle`}
/>
<InfoRow label="Extra credits" value={`${quota.extraCredits}`} />
{user.plan === "PREMIUM" && user.subscribedAt && (
<InfoRow
label="Subscribed since"
value={user.subscribedAt.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
/>
)}
{user.plan === "PREMIUM" && (
<InfoRow
label="Payment method"
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
/>
)}
{extensionUsage && (
<InfoRow
label="Extension requests (this year)"
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
/>
)}
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
</dl>
{user.plan === "FREE" ? (
<>
<p className="mt-4 text-sm text-gray-400">
<UpgradeProButton /> for 50 videos/month, 1080p, API access, and lossless audio.
</p>
{!selfHosted && (
<CreditPurchasePanel
initialCredits={user.extraCredits}
stripeConfigured={isStripeConfigured() || isBillingDevMock()}
recentPurchases={recentCreditPurchases.map((p) => ({
id: p.id,
credits: p.credits,
amountCents: p.amountCents,
completedAt: p.completedAt?.toISOString() ?? null,
}))}
/>
)}
</>
) : (
<>
{extensionUsage && <PlanBillingActions initialUsage={extensionUsage} />}
<p className="mt-6 border-t border-gray-800 pt-6 text-sm text-gray-400">
Pro uses your monthly allocation first, then any never-expiring extra credits.
</p>
</>
)}
</section> </section>
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
<ApiKeySettings
initialStatus={apiKeyStatus}
initialRateLimit={apiRateLimit}
initialExtensionUsage={apiRateExtensionUsage}
/>
</section>
)}
<section className="rounded-lg border border-gray-700 bg-surface-light p-6"> <section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion &amp; data</h2> <h2 className="mb-2 text-lg font-semibold text-white">Account deletion &amp; data</h2>
<p className="mb-4 text-sm text-gray-400"> <p className="mb-4 text-sm text-gray-400">
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.
</p> </p>
<AccountPrivacyActions email={user.email} /> <AccountPrivacyActions email={user.email} />
</section> </section>
+3 -62
View File
@@ -2,75 +2,18 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "./providers"; import { Providers } from "./providers";
import { JsonLd } from "@/components/JsonLd";
import { Matomo } from "@/components/Matomo";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
const SITE_URL = "https://songs2vid.com";
const SITE_NAME = "Songs2VID"; const SITE_NAME = "Songs2VID";
const DEFAULT_TITLE = const DEFAULT_TITLE = "Songs2VID";
"Songs2VID - Fast Audio to Video Converter for YouTube | TunesToTube Alternative";
const DEFAULT_DESCRIPTION = 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."; "Self-hosted audio-to-video creation and YouTube uploading.";
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.";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE_URL), title: DEFAULT_TITLE,
title: {
default: DEFAULT_TITLE,
template: `%s | ${SITE_NAME}`,
},
description: DEFAULT_DESCRIPTION, 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, 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: { icons: {
icon: "/favicon.png", icon: "/favicon.png",
}, },
@@ -84,8 +27,6 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning> <body className={inter.className} suppressHydrationWarning>
<JsonLd />
<Matomo />
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>
+12 -79
View File
@@ -1,94 +1,27 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { BenefitsSection } from "@/components/BenefitsSection"; import { Logo } from "@/components/Logo";
import { DownloadSection } from "@/components/DownloadSection";
import { LandingNavbar } from "@/components/LandingNavbar";
import { SignInButton } from "@/components/SignInButton"; 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() { export default async function HomePage() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (session) redirect("/dashboard");
return ( return (
<main className="min-h-screen"> <main className="flex min-h-dvh items-center justify-center bg-surface px-6">
<section className="relative min-h-dvh overflow-hidden"> <div className="w-full max-w-sm rounded-xl border border-gray-700 bg-surface-light p-8 text-center shadow-xl">
<video <div className="mb-6 flex justify-center">
autoPlay <Logo size="lg" />
muted </div>
loop
playsInline
className="absolute inset-0 h-full w-full object-cover"
aria-hidden="true"
>
<source src="/bg-video.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/55" aria-hidden="true" />
<div
className="absolute inset-0 bg-gradient-to-b from-black/70 via-black/40 to-black/80"
aria-hidden="true"
/>
<LandingNavbar />
{/* Purpose + brand above the fold for Google OAuth verification */}
<div className="relative z-10 mx-auto flex min-h-dvh max-w-3xl flex-col justify-center px-6 pb-16 pt-28 text-center">
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl lg:text-7xl">
<span className="text-white">Songs</span>
<span className="text-red-400">2VID</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-gray-200 sm:text-lg md:text-xl">
Convert MP3, WAV, and audio files into YouTube videos instantly a fast, privacy-focused
audio to video converter and TunesToTube alternative that uploads music, beats, and
podcasts directly to your channel.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
{session ? (
<Link
href="/dashboard"
className="inline-flex items-center gap-2 rounded bg-accent px-8 py-3.5 font-medium text-white transition-all duration-300 hover:scale-[1.02] hover:bg-accent-hover hover:shadow-lg hover:shadow-accent/20"
>
Go to Dashboard
</Link>
) : (
<>
<SignInButton large /> <SignInButton large />
<p className="max-w-md text-xs leading-relaxed text-gray-400"> <p className="mt-5 text-xs leading-relaxed text-gray-500">
By clicking Continue with Google, you accept our{" "} By continuing, you accept the{" "}
<Link <Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
href="/terms" <Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-gray-300 underline-offset-2 transition-colors hover:text-white hover:underline"
>
Privacy Policy
</Link>
.
</p> </p>
</>
)}
</div> </div>
</div>
</section>
<StepsSection />
<BenefitsSection />
<DownloadSection />
<PricingSection />
<SupportSection />
<Footer />
</main> </main>
); );
} }
+5 -304
View File
@@ -1,314 +1,15 @@
import type { Metadata } from "next";
import { LegalPageLayout } from "@/components/LegalPageLayout"; 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() { export default function PrivacyPage() {
return ( return (
<LegalPageLayout <LegalPageLayout title="Privacy Policy">
title="Privacy Policy"
description="How we handle your personal data when you use Songs2VID."
>
<h2>1. Overview</h2>
<p> <p>
This Privacy Policy explains how {LEGAL_OPERATOR.name} (&quot;we&quot;, &quot;us&quot;) Songs2VID is self-hosted software. Your operator controls the deployment, database, uploads,
processes personal data when you use our website, hosted cloud service, and related features logs, and Google OAuth configuration. Songs2VID does not include payment processing.
that convert audio and images into videos for upload to YouTube.
</p> </p>
<p> <p>
We process personal data in accordance with applicable data protection laws, including the Google and YouTube process account and upload data according to their own policies. Contact
General Data Protection Regulation (GDPR) where it applies. the operator of this instance for data access or deletion requests.
</p>
<h2>2. Data controller</h2>
<p>
{LEGAL_OPERATOR.legalName}
<br />
{LEGAL_OPERATOR.address}
<br />
{LEGAL_OPERATOR.city}
<br />
Email: <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p>
<h2>3. What data we collect</h2>
<h3>3.1 Account and authentication data</h3>
<p>When you sign in with Google, we receive and store:</p>
<ul>
<li>Your name, email address, and profile image (from Google)</li>
<li>OAuth tokens required to authenticate your session</li>
<li>YouTube connection data, including channel ID and title</li>
<li>
Encrypted YouTube API access and refresh tokens needed to upload videos, create or list
playlists, and manage related YouTube actions you request
</li>
</ul>
<h3>3.2 Uploaded content and job data</h3>
<p>When you use the service, we temporarily process:</p>
<ul>
<li>Image and audio files you upload (including optional per-track cover images)</li>
<li>Optional custom watermark assets (text, PNG logo, or font files)</li>
<li>Generated video files prior to or during YouTube upload</li>
<li>
Per-video metadata you provide (title, artist, description, tags, privacy, category,
resolution, layout, watermark settings, Made for Kids / embedding / license flags, etc.)
</li>
<li>
Embedded audio tag metadata (for example ID3 title/artist/album) when we read it from
uploaded MP3 files to help prefill fields
</li>
<li>Playlist titles and IDs when you create or attach YouTube playlists through the Service</li>
</ul>
<h3>3.3 Usage, API, and technical data</h3>
<ul>
<li>
Plan type, monthly credit allocation, credits used, purchased extra credits, quota reset
dates, and job processing status
</li>
<li>
Quota reset / extension and API rate-limit extension request history (Pro) when you submit
a request
</li>
<li>
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
</li>
<li>IP address, browser type, device information, and request logs</li>
<li>Error reports and operational diagnostics</li>
<li>
Website and product analytics events collected via Matomo (self-hosted at
analytics.atakanozban.com) to understand traffic and improve the Service see section
10
</li>
</ul>
<h3>3.4 Payment data</h3>
<p>
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.
</p>
<h2>4. Why we process your data</h2>
<ul>
<li>
<strong>Contract performance:</strong> to provide video encoding, metadata handling,
playlist actions, API access, and YouTube upload features you request
</li>
<li>
<strong>Legitimate interests:</strong> to secure our service, prevent abuse, improve
reliability, and enforce our terms
</li>
<li>
<strong>Legal obligations:</strong> where required by tax, accounting, or regulatory law
</li>
<li>
<strong>Consent:</strong> where you have given explicit consent, such as optional
marketing communications if offered
</li>
</ul>
<h2>5. Third-party services</h2>
<p>We use trusted third parties to operate Songs2VID, including:</p>
<ul>
<li>
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
the YouTube Data API. Your use of Google and YouTube is also subject to{" "}
<a
href="https://policies.google.com/privacy"
target="_blank"
rel="noopener noreferrer"
>
Google&apos;s Privacy Policy
</a>
,{" "}
<a
href="https://www.youtube.com/t/terms"
target="_blank"
rel="noopener noreferrer"
>
YouTube Terms of Service
</a>
, and related Google API terms
</li>
<li>
<strong>Hosting and infrastructure providers:</strong> servers, databases, queues, and
storage
</li>
<li>
<strong>Stripe:</strong> Pro subscriptions, Free-plan credit top-ups, and related billing
webhooks (
<a href="https://stripe.com/privacy" target="_blank" rel="noopener noreferrer">
Stripe Privacy Policy
</a>
)
</li>
<li>
<strong>Matomo (self-hosted analytics):</strong> 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.
</li>
</ul>
<p>
These providers process data only as necessary to deliver their services and under
appropriate contractual safeguards where required.
</p>
<h2>6. Google / YouTube user data (Limited Use)</h2>
<p>
Songs2VID&apos;s use and transfer to any other app of information received from Google APIs
will adhere to the{" "}
<a
href="https://developers.google.com/terms/api-services-user-data-policy"
target="_blank"
rel="noopener noreferrer"
>
Google API Services User Data Policy
</a>
, including the Limited Use requirements.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
You can revoke Songs2VID&apos;s access to your Google account at any time in{" "}
<a
href="https://security.google.com/settings/security/permissions"
target="_blank"
rel="noopener noreferrer"
>
Google Account Security Third-party access
</a>
. 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.
</p>
<h2>7. Data retention and account deletion</h2>
<ul>
<li>
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)
</li>
<li>Account data is kept while your account remains active</li>
<li>Billing records may be retained as required by law</li>
<li>Logs are retained for a limited period for security and troubleshooting</li>
<li>
API key hashes are removed when you revoke the key, downgrade from Pro (where applicable),
or delete your account
</li>
</ul>
<p>
You may delete your account from <strong>Dashboard Settings</strong> (account deletion
control) or by emailing{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. 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.
</p>
<h2>8. Self-hosted deployments</h2>
<p>
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.
</p>
<h2>9. Your rights</h2>
<p>Depending on your location, you may have the right to:</p>
<ul>
<li>Access the personal data we hold about you</li>
<li>Request correction or deletion</li>
<li>Restrict or object to certain processing</li>
<li>Data portability</li>
<li>Withdraw consent where processing is consent-based</li>
<li>
Lodge a complaint with a supervisory authority (in Hungary, the Nemzeti Adatvédelmi és
Információszabadság Hatóság NAIH)
</li>
</ul>
<p>
To exercise these rights, contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>, or use in-product
account deletion where available. You may also revoke Google access as described in section
6.
</p>
<h2>10. Cookies, analytics, and local storage</h2>
<p>
We use essential cookies and similar technologies for authentication, session management,
and security.
</p>
<p>
We also use <strong>Matomo</strong>, a self-hosted analytics tool on{" "}
<code>analytics.atakanozban.com</code>, 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 <code>*.songs2vid.com</code>.
</p>
<p>
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{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>.
</p>
<h2>11. Security</h2>
<p>
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.
</p>
<h2>12. International transfers</h2>
<p>
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.
</p>
<h2>13. Children</h2>
<p>
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.
</p>
<h2>14. Changes to this policy</h2>
<p>
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.
</p>
<h2>15. Contact</h2>
<p>
Questions about this Privacy Policy or our privacy practices:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p> </p>
</LegalPageLayout> </LegalPageLayout>
); );
-119
View File
@@ -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 (
<LegalPageLayout
title="Refund Policy"
description="Our policy on refunds, cancellations, and billing for paid plans."
>
<h2>1. Overview</h2>
<p>
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.
</p>
<h2>2. Free plan and extra credits</h2>
<p>
The Bedroom Producer (Free) monthly allocation is provided at no charge. Free users may
optionally purchase extra video credits (currently 0.25 each, 115 per checkout, Free extras
balance capped at 15). Once extra credits are granted to your account, those one-time
purchases are <strong>generally non-refundable</strong>, except where mandatory consumer law
requires otherwise. Credits trimmed under the account accumulation cap (see our{" "}
<Link href="/terms">Terms of Service</Link>) are not refundable.
</p>
<h2>3. Pro subscriptions</h2>
<h3>3.1 Billing cycle</h3>
<p>
Pro plans are billed on a recurring monthly basis unless otherwise stated at checkout.
Your subscription renews automatically until cancelled.
</p>
<h3>3.2 14-day refund window</h3>
<p>
If you are a new Pro subscriber, you may request a full refund within <strong>14 days</strong> 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).
</p>
<h3>3.3 After the refund window</h3>
<p>
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.
</p>
<h3>3.4 Cancellation</h3>
<p>
You can cancel your subscription through your account billing settings (choose{" "}
<strong>cancel immediately</strong> or <strong>at the end of the billing period</strong>) or
via the Stripe customer portal / by contacting{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. 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{" "}
<strong>Dashboard Settings</strong> or contact us if you want permanent account deletion.
</p>
<h2>4. Enterprise and custom agreements</h2>
<p>
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{" "}
<a href={`mailto:${LEGAL_OPERATOR.salesEmail}`}>{LEGAL_OPERATOR.salesEmail}</a> for
contract-related billing questions.
</p>
<h2>5. Non-refundable situations</h2>
<p>Refunds are generally not provided when:</p>
<ul>
<li>The refund request is made outside the applicable refund window</li>
<li>The account was terminated for violation of our <Link href="/terms">Terms of Service</Link></li>
<li>The issue is caused by third-party services outside our control (e.g. YouTube API outages, Google account restrictions)</li>
<li>You simply changed your mind after substantial use of paid quota or features</li>
<li>Purchased Free-plan extra credits have already been granted to your account</li>
<li>Credits expired or were trimmed under the 30-credit accumulation cap</li>
<li>One-time setup or license fees after delivery of agreed setup work, unless required by law or contract</li>
</ul>
<h2>6. Chargebacks</h2>
<p>
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.
</p>
<h2>7. How to request a refund</h2>
<p>Email us at <a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a> with:</p>
<ul>
<li>Your account email address</li>
<li>Date of purchase and invoice or transaction reference if available</li>
<li>Reason for the refund request</li>
</ul>
<p>We aim to respond within 5 business days. Approved refunds are issued to the original payment method where possible.</p>
<h2>8. Consumer rights</h2>
<p>
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.
</p>
<h2>9. Changes</h2>
<p>
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.
</p>
</LegalPageLayout>
);
}
+5 -251
View File
@@ -1,261 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LegalPageLayout } from "@/components/LegalPageLayout"; 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() { export default function TermsPage() {
return ( return (
<LegalPageLayout <LegalPageLayout title="Terms of Use">
title="Terms of Service"
description="Please read these terms carefully before using Songs2VID."
>
<h2>1. Agreement</h2>
<p> <p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the Songs2VID Songs2VID is provided as open-source, self-hosted software without warranty. The operator of
website and hosted cloud service (the &quot;Service&quot;) operated by{" "} each instance is responsible for availability, configuration, and user access.
{LEGAL_OPERATOR.legalName} (&quot;we&quot;, &quot;us&quot;). By creating an account or using
the Service, you agree to these Terms.
</p> </p>
<p> <p>
If you do not agree, do not use the Service. If you self-host the open-source software on You are responsible for the media you process and upload, including compliance with
your own infrastructure without using our hosted Service, these Terms apply only to the copyright law and YouTube&apos;s terms.
extent you use our website, support channels, or paid services we provide.
</p>
<h2>2. The Service</h2>
<p>
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.
</p>
<ul>
<li>
<strong>Bedroom Producer (Free):</strong> 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
</li>
<li>
<strong>Independent Artist (Pro):</strong> 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)
</li>
<li>
<strong>Enterprise (Record Label / Studio):</strong> custom terms as agreed in writing
</li>
</ul>
<p>
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.
</p>
<h2>3. Eligibility and accounts</h2>
<ul>
<li>You must be at least 16 years old or the age required in your jurisdiction</li>
<li>You must have a valid Google account and authorized access to the YouTube channel you connect</li>
<li>You are responsible for maintaining the security of your account and OAuth connection</li>
<li>You must provide accurate information and promptly update it if it changes</li>
</ul>
<h2>4. Your content and responsibilities</h2>
<p>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.</p>
<p>You represent and warrant that:</p>
<ul>
<li>You own or have all necessary rights to the content you upload</li>
<li>Your content and use of the Service comply with applicable law and YouTube policies</li>
<li>Your content does not infringe third-party rights or contain unlawful material</li>
<li>You have configured metadata (including &quot;Made for Kids&quot; and privacy settings) accurately</li>
</ul>
<p>
You are solely responsible for content published to your YouTube channel through the
Service.
</p>
<h2>5. Acceptable use</h2>
<p>You agree not to:</p>
<ul>
<li>Use the Service for unlawful, harmful, or abusive purposes</li>
<li>Upload malware, attempt unauthorized access, or interfere with the Service</li>
<li>Circumvent quotas, plan limits, or technical restrictions</li>
<li>Resell or commercially exploit the hosted Service without authorization</li>
<li>Use the Service in a way that violates Google, YouTube, or third-party terms</li>
</ul>
<p>We may suspend or terminate access for violations or risks to the Service or other users.</p>
<h2>6. YouTube and third-party services</h2>
<p>
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{" "}
<a href="https://policies.google.com/terms" target="_blank" rel="noopener noreferrer">
Google Terms of Service
</a>
,{" "}
<a href="https://www.youtube.com/t/terms" target="_blank" rel="noopener noreferrer">
YouTube Terms of Service
</a>
,{" "}
<a
href="https://developers.google.com/youtube/terms/api-services-terms-of-service"
target="_blank"
rel="noopener noreferrer"
>
YouTube API Services Terms
</a>
, and{" "}
<a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">
Google&apos;s Privacy Policy
</a>
. 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{" "}
<Link href="/privacy">Privacy Policy</Link>.
</p>
<h2>7. Open-source software</h2>
<p>
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.
</p>
<h2>8. Subscription, Billing &amp; Credits</h2>
<p>
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{" "}
<Link href="/refund">Refund Policy</Link>. Failure to pay may result in downgrade or
suspension.
</p>
<p>
<strong>Credit usage order:</strong> when you create video jobs, we deduct from your current
monthly allocation first, then from any purchased extra credits.
</p>
<h3>8.1 Pro plan quota and rate-limit extensions</h3>
<p>
Independent Artist (Pro) subscribers receive <strong>50 video credits</strong> 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).
</p>
<p>
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{" "}
<a href={`mailto:${LEGAL_OPERATOR.quotaRequestEmail}`}>
{LEGAL_OPERATOR.quotaRequestEmail}
</a>
. Each Pro account is entitled to up to{" "}
<strong>five (5) video-quota reset or extension requests per calendar year</strong> and up
to <strong>five (5) API rate-limit extension requests per calendar year</strong>. 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.
</p>
<h3>8.2 Free plan extra credits</h3>
<p>
Bedroom Producer (Free) accounts receive <strong>10 video credits</strong> per calendar
month. Free users may purchase additional never-expiring extra credits at the price shown at
checkout (currently <strong>0.25 per credit</strong>), in packs of{" "}
<strong>1 to 15 credits</strong> per purchase. On the Free plan, the extras balance may not
exceed <strong>15 credits</strong>. 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.
</p>
<h3>8.3 Credit rollover &amp; accumulation cap</h3>
<p>
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{" "}
<strong>30 credits</strong> 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.
</p>
<h3>8.4 Pro API access</h3>
<p>
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.
</p>
<h2>9. Availability and support</h2>
<p>
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.
</p>
<h2>10. Disclaimer of warranties</h2>
<p>
THE SERVICE IS PROVIDED &quot;AS IS&quot; AND &quot;AS AVAILABLE&quot; 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.
</p>
<h2>11. Limitation of liability</h2>
<p>
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.
</p>
<p>
Some jurisdictions do not allow certain limitations, so some of the above may not apply to
you.
</p>
<h2>12. Indemnification</h2>
<p>
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.
</p>
<h2>13. Termination and account deletion</h2>
<p>
You may stop using the Service at any time. You may cancel a paid subscription (see our{" "}
<Link href="/refund">Refund Policy</Link>) without deleting your account. To permanently
delete your account and associated active data, use the account deletion control in{" "}
<strong>Dashboard Settings</strong> or contact{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>. 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 <Link href="/privacy">Privacy Policy</Link>.
</p>
<h2>14. Governing law</h2>
<p>
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.
</p>
<h2>15. Changes</h2>
<p>
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.
</p>
<h2>16. Contact</h2>
<p>
Questions about these Terms:{" "}
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
</p> </p>
</LegalPageLayout> </LegalPageLayout>
); );
Binary file not shown.
Binary file not shown.
-349
View File
@@ -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<AdminBadge[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-2 text-lg font-semibold text-white">Admin unlock</h2>
<p className="mb-4 text-sm text-gray-400">
Enter <code className="text-gray-300">ADMIN_API_KEY</code> to manage footer badges.
The key stays in this browser tab only (sessionStorage).
</p>
<form onSubmit={unlock} className="flex flex-col gap-3 sm:flex-row">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="ADMIN_API_KEY"
className="input-field flex-1"
autoComplete="off"
/>
<button
type="submit"
disabled={loading}
className="rounded bg-accent px-4 py-2 text-sm font-medium text-black hover:opacity-90 disabled:opacity-50"
>
{loading ? "Checking…" : "Unlock"}
</button>
</form>
{error ? <p className="mt-3 text-sm text-red-400">{error}</p> : null}
</section>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-gray-400">
Authenticated with admin API key. Changes apply to the live footer marquee.
</p>
<button
type="button"
onClick={lock}
className="shrink-0 text-sm text-gray-400 underline-offset-2 hover:text-white hover:underline"
>
Lock
</button>
</div>
{error ? <p className="text-sm text-red-400">{error}</p> : null}
{success ? <p className="text-sm text-emerald-400">{success}</p> : null}
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<h2 className="mb-1 text-lg font-semibold text-white">Add badge</h2>
<p className="mb-4 text-sm text-gray-400">
Paste a Product Hunt / LaunchBuff embed (HTML), or set image + link URLs.
</p>
<form onSubmit={createBadge} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="input-field"
placeholder="Product Hunt"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
Embed HTML / script
</label>
<textarea
value={embedHtml}
onChange={(e) => setEmbedHtml(e.target.value)}
className="input-field min-h-[120px] font-mono text-xs"
placeholder='<a href="..."><img src="..." /></a>'
spellCheck={false}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
Image URL (optional)
</label>
<input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="input-field"
placeholder="https://…"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-gray-500">
Link URL (optional)
</label>
<input
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="input-field"
placeholder="https://…"
/>
</div>
</div>
<button
type="submit"
disabled={saving}
className="rounded bg-accent px-4 py-2 text-sm font-medium text-black hover:opacity-90 disabled:opacity-50"
>
{saving ? "Saving…" : "Add badge"}
</button>
</form>
</section>
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Badges</h2>
<button
type="button"
onClick={() => loadBadges(apiKey)}
disabled={loading}
className="text-sm text-gray-400 hover:text-white"
>
{loading ? "Refreshing…" : "Refresh"}
</button>
</div>
{badges.length === 0 ? (
<p className="text-sm text-gray-500">No badges yet.</p>
) : (
<ul className="divide-y divide-gray-800">
{badges.map((badge) => (
<li
key={badge.id}
className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-white">{badge.name}</span>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
badge.isActive
? "bg-emerald-500/15 text-emerald-400"
: "bg-gray-700 text-gray-400"
}`}
>
{badge.isActive ? "Active" : "Inactive"}
</span>
<span className="text-xs text-gray-600">order {badge.sortOrder}</span>
</div>
<p className="mt-1 truncate text-xs text-gray-500">
{badge.linkUrl || badge.imageUrl || (badge.embedHtml ? "embed HTML" : "—")}
</p>
{badge.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={badge.imageUrl}
alt=""
className="mt-2 h-10 w-auto max-w-[200px] object-contain opacity-90"
/>
) : null}
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => toggleActive(badge)}
className="rounded border border-gray-600 px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 hover:text-white"
>
{badge.isActive ? "Deactivate" : "Activate"}
</button>
<button
type="button"
onClick={() => removeBadge(badge)}
className="rounded border border-red-900/60 px-3 py-1.5 text-xs text-red-400 hover:border-red-500 hover:text-red-300"
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+17 -209
View File
@@ -1,95 +1,34 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { API_DOCS_URL } from "@/lib/plans"; import { API_DOCS_URL } from "@/lib/plans";
type ApiKeyStatus = { type Props = {
configured: boolean; initialStatus: { configured: boolean; prefix: string | null };
prefix: string | null; initialRateLimit: {
};
type RateLimitStatus = {
limit: number; limit: number;
used: number; used: number;
remaining: number; remaining: number;
windowSeconds: number; windowSeconds: number;
resetsInSeconds: number; resetsInSeconds: number;
bonus?: number; };
}; };
type ExtensionRequest = { export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
id: string;
status: string;
message: string;
requestedAt: string;
processedAt: string | null;
adminNote: string | null;
};
type ExtensionUsage = {
used: number;
limit: number;
remaining: number;
requests: ExtensionRequest[];
};
type Props = {
initialStatus: ApiKeyStatus;
initialRateLimit: RateLimitStatus;
initialExtensionUsage: ExtensionUsage;
};
export function ApiKeySettings({
initialStatus,
initialRateLimit,
initialExtensionUsage,
}: Props) {
const [status, setStatus] = useState(initialStatus); const [status, setStatus] = useState(initialStatus);
const [rateLimit, setRateLimit] = useState(initialRateLimit);
const [extensionUsage, setExtensionUsage] = useState(initialExtensionUsage);
const [newKey, setNewKey] = useState<string | null>(null); const [newKey, setNewKey] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [requesting, setRequesting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let active = true;
const refresh = () => {
fetch("/api/account/api-rate-limit")
.then(async (res) => {
if (!res.ok) return;
const data = await res.json();
if (active) setRateLimit(data);
})
.catch(() => {});
};
refresh();
const id = setInterval(refresh, 5000);
return () => {
active = false;
clearInterval(id);
};
}, []);
async function generateKey() { async function generateKey() {
if ( if (status.configured && !confirm("Replace your existing API key?")) return;
status.configured &&
!confirm("This will replace your existing API key. Continue?")
) {
return;
}
setLoading(true); setLoading(true);
setError(null); setError(null);
setNewKey(null); setNewKey(null);
setSuccess(null);
try { try {
const res = await fetch("/api/account/api-key", { method: "POST" }); const res = await fetch("/api/account/api-key", { method: "POST" });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to generate API key"); if (!res.ok) throw new Error(data.error || "Failed to generate API key");
setNewKey(data.apiKey); setNewKey(data.apiKey);
setStatus({ configured: true, prefix: data.prefix }); setStatus({ configured: true, prefix: data.prefix });
} catch (err) { } catch (err) {
@@ -100,17 +39,13 @@ export function ApiKeySettings({
} }
async function revokeKey() { async function revokeKey() {
if (!confirm("Revoke your API key? External integrations will stop working.")) return; if (!confirm("Revoke your API key?")) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
setSuccess(null);
try { try {
const res = await fetch("/api/account/api-key", { method: "DELETE" }); const res = await fetch("/api/account/api-key", { method: "DELETE" });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to revoke API key"); if (!res.ok) throw new Error(data.error || "Failed to revoke API key");
setStatus({ configured: false, prefix: null }); setStatus({ configured: false, prefix: null });
setNewKey(null); setNewKey(null);
} catch (err) { } catch (err) {
@@ -120,143 +55,34 @@ export function ApiKeySettings({
} }
} }
async function requestRateLimitExtension() {
if (extensionUsage.remaining <= 0) return;
const reason = prompt(
"Optional: tell us why you need a higher API rate limit (leave blank to skip).",
);
if (reason === null) return;
setRequesting(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/api-rate-limit-extension-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to submit request");
setExtensionUsage({
used: data.used,
limit: data.limit,
remaining: data.remaining,
requests: data.requests ?? extensionUsage.requests,
});
setSuccess(
`Rate limit extension request submitted. ${data.used} of ${data.limit} used this year.`,
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit request");
} finally {
setRequesting(false);
}
}
const hasPending = extensionUsage.requests.some((r) => r.status === "PENDING");
const usedPct = Math.min(100, Math.round((rateLimit.used / Math.max(1, rateLimit.limit)) * 100));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Use the REST API to upload files and create batch video jobs programmatically. Pro plan Use the REST API to upload files and create video jobs programmatically.
only.
</p> </p>
<div className="rounded border border-gray-800 bg-surface p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium text-white">API rate limit</p>
<p className="text-xs text-gray-400">
Resets in {rateLimit.resetsInSeconds}s
</p>
</div>
<p className="text-sm text-gray-300">
{rateLimit.used} / {rateLimit.limit} requests used this minute
{rateLimit.bonus ? (
<span className="text-gray-500"> (includes +{rateLimit.bonus} bonus)</span>
) : null}
</p>
<div className="h-2 overflow-hidden rounded bg-gray-800">
<div
className={`h-full rounded transition-all ${
usedPct >= 90 ? "bg-red-500" : usedPct >= 70 ? "bg-amber-500" : "bg-accent"
}`}
style={{ width: `${usedPct}%` }}
/>
</div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
</p> </p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-gray-400">
Extension requests this year: {extensionUsage.used} / {extensionUsage.limit}
</p>
<button
type="button"
onClick={requestRateLimitExtension}
disabled={requesting || extensionUsage.remaining <= 0 || hasPending}
className="inline-flex items-center justify-center rounded border border-accent/40 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/10 disabled:cursor-not-allowed disabled:opacity-50"
>
{requesting
? "Submitting…"
: hasPending
? "Pending request"
: extensionUsage.remaining <= 0
? "No extensions left"
: "Request rate limit extension"}
</button>
</div>
{extensionUsage.requests.length > 0 && (
<ul className="space-y-1 border-t border-gray-800 pt-3 text-xs text-gray-500">
{extensionUsage.requests.slice(0, 5).map((req) => (
<li key={req.id}>
{new Date(req.requestedAt).toLocaleDateString()} · {req.status}
{req.adminNote ? ` · ${req.adminNote}` : ""}
</li>
))}
</ul>
)}
</div>
{status.configured && status.prefix && ( {status.configured && status.prefix && (
<p className="text-sm text-gray-300"> <p className="text-sm text-gray-300">
Active key: <span className="font-mono text-white">{status.prefix}</span> Active key: <span className="font-mono text-white">{status.prefix}</span>
</p> </p>
)} )}
{newKey && ( {newKey && (
<div className="rounded border border-green-500/40 bg-green-500/10 p-4"> <div className="rounded border border-green-500/40 bg-green-500/10 p-4">
<p className="mb-2 text-sm font-medium text-green-300">Your new API key (copy now)</p> <p className="mb-2 text-sm font-medium text-green-300">Copy your new API key now</p>
<code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200"> <code className="block break-all rounded bg-black/40 px-3 py-2 text-xs text-green-200">
{newKey} {newKey}
</code> </code>
</div> </div>
)} )}
{error && <p className="text-sm text-red-300">{error}</p>}
{success && ( <div className="flex flex-wrap gap-3">
<div className="rounded border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button <button
type="button" type="button"
onClick={generateKey} onClick={generateKey}
disabled={loading} disabled={loading}
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50" className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover disabled:opacity-50"
> >
{loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"} {loading ? "Working…" : status.configured ? "Regenerate API key" : "Generate API key"}
</button> </button>
@@ -265,37 +91,19 @@ export function ApiKeySettings({
type="button" type="button"
onClick={revokeKey} onClick={revokeKey}
disabled={loading} disabled={loading}
className="inline-flex items-center justify-center rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 transition-colors hover:bg-red-500/10 disabled:opacity-50" className="rounded border border-red-600/50 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10"
> >
Revoke API key Revoke API key
</button> </button>
)} )}
</div>
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
<ul className="space-y-2 font-mono">
<li>POST /api/v1/upload: upload image or audio file</li>
<li>POST /api/v1/jobs: create job from paths (recommended for large batches)</li>
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
<li>GET /api/v1/playlists: list YouTube playlists</li>
<li>POST /api/v1/playlists: create a YouTube playlist</li>
<li>GET /api/v1/jobs: list jobs</li>
<li>GET /api/v1/jobs/:id: job status</li>
</ul>
<p className="mt-3">
Send <span className="text-gray-300">Authorization: Bearer YOUR_API_KEY</span> on every
request. For many audio files, upload each file then call{" "}
<span className="text-gray-300">/api/v1/jobs</span> (avoid large one-shot batches).{" "}
<a <a
href={API_DOCS_URL} href={API_DOCS_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-accent hover:underline" className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:text-white"
> >
Full API docs API documentation
</a> </a>
</p>
</div> </div>
</div> </div>
); );
-73
View File
@@ -1,73 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
type PublicBadge = {
id: string;
name: string;
embedHtml: string | null;
imageUrl: string | null;
linkUrl: string | null;
};
/**
* Renders a badge embed safely inside the marquee slot.
* Prefer structured image+link; fall back to HTML (re-executing scripts when present).
*/
export function BadgeEmbed({ badge }: { badge: PublicBadge }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
host.replaceChildren();
if (badge.imageUrl && badge.linkUrl) {
const a = document.createElement("a");
a.href = badge.linkUrl;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.title = badge.name;
a.className = "badge-marquee-link";
const img = document.createElement("img");
img.src = badge.imageUrl;
img.alt = badge.name;
img.loading = "lazy";
img.decoding = "async";
a.appendChild(img);
host.appendChild(a);
return;
}
if (!badge.embedHtml) return;
const template = document.createElement("template");
template.innerHTML = badge.embedHtml.trim();
const scripts: HTMLScriptElement[] = [];
template.content.querySelectorAll("script").forEach((node) => {
scripts.push(node.cloneNode(true) as HTMLScriptElement);
node.remove();
});
host.appendChild(template.content.cloneNode(true));
for (const oldScript of scripts) {
const script = document.createElement("script");
for (const attr of oldScript.attributes) {
script.setAttribute(attr.name, attr.value);
}
if (oldScript.textContent) script.textContent = oldScript.textContent;
host.appendChild(script);
}
}, [badge.embedHtml, badge.imageUrl, badge.linkUrl, badge.name]);
return (
<div
ref={hostRef}
className="badge-marquee-slot"
data-badge-id={badge.id}
data-badge-name={badge.name}
/>
);
}
-436
View File
@@ -1,436 +0,0 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { useInView } from "@/hooks/useInView";
import { useMockupProgress } from "@/hooks/useMockupProgress";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
function FilmStripIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M2 8h20M2 16h20M6 4v4M6 16v4M10 4v4M10 16v4M14 4v4M14 16v4M18 4v4M18 16v4" />
<path d="m15 9 3 3-3 3" />
</svg>
);
}
function YouTubeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8z" />
<path fill="#12121f" d="M9.75 15.02l6.35-3.02-6.35-3.02v6.04z" />
</svg>
);
}
function CoinsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<ellipse cx="8" cy="6" rx="5" ry="2" />
<path d="M3 6v4c0 1.1 2.24 2 5 2s5-.9 5-2V6" />
<path d="M3 10v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
<ellipse cx="16" cy="14" rx="5" ry="2" />
<path d="M11 14v4c0 1.1 2.24 2 5 2s5-.9 5-2v-4" />
</svg>
);
}
function PlaylistIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 6h12M4 12h12M4 18h8" strokeLinecap="round" />
<path d="M17 14.5v5l4-2.5-4-2.5z" fill="currentColor" stroke="none" />
</svg>
);
}
function NoteIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
function WaveIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 12h1M7 12h1M10 8v8M13 10v4M16 7v10M19 11v2" strokeLinecap="round" />
</svg>
);
}
function GearsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
);
}
const BENEFITS = [
{
title: "No editing required",
desc: "Skip complex video editors. Just upload an image and audio, and Songs2VID handles the rest.",
Icon: FilmStripIcon,
},
{
title: "YouTube-ready output",
desc: "Videos are encoded and uploaded with the metadata you set, ready for your channel.",
Icon: YouTubeIcon,
},
{
title: "Free tier included",
desc: "Start creating with 10 videos every month at up to 720p. No credit card needed.",
Icon: CoinsIcon,
},
{
title: "YouTube playlists",
desc: "Independent Artist (Pro) can create YouTube playlists and add every upload from the dashboard or API.",
Icon: PlaylistIcon,
},
] as const;
const STOCK_THUMBS = [
"https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1514320291840-2e0a9bf2a9ae?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1487180144351-b8472da7d491?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1571330735066-03aaa9429d89?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1619983081563-430f63602796?w=120&h=120&fit=crop&auto=format",
"https://images.unsplash.com/photo-1483412033650-1015ddeb83d1?w=120&h=120&fit=crop&auto=format",
] as const;
function TypewriterText({ text }: { text: string }) {
const [displayed, setDisplayed] = useState("");
const [done, setDone] = useState(false);
useEffect(() => {
let index = 0;
const interval = setInterval(() => {
index += 1;
setDisplayed(text.slice(0, index));
if (index >= text.length) {
clearInterval(interval);
setDone(true);
}
}, 45);
return () => clearInterval(interval);
}, [text]);
return (
<span className="text-xs font-medium text-gray-300">
{displayed}
{!done && <span className="ml-0.5 inline-block w-[2px] animate-pulse bg-red-400">|</span>}
</span>
);
}
function BenefitCard({
title,
desc,
Icon,
}: {
title: string;
desc: string;
Icon: typeof FilmStripIcon;
}) {
return (
<div className="group relative overflow-hidden rounded-xl border border-red-500/20 bg-surface p-6 shadow-[0_0_24px_rgba(239,68,68,0.08)] transition-all duration-300 hover:border-red-500/40 hover:shadow-[0_0_32px_rgba(239,68,68,0.18)]">
<Icon className="absolute right-5 top-5 h-8 w-8 text-gray-600 transition-colors duration-300 group-hover:text-red-500/50" />
<h3 className="pr-12 text-lg font-semibold text-white">{title}</h3>
<p className="mt-3 text-sm leading-relaxed text-gray-400">{desc}</p>
</div>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function MockupProgressRow({
initialWidth,
midWidth,
active,
phaseOneMs = 3000,
phaseTwoMs = 2000,
}: {
initialWidth: string;
midWidth: string;
active: boolean;
phaseOneMs?: number;
phaseTwoMs?: number;
}) {
const { phase, processed, transitionMs } = useMockupProgress(active, phaseOneMs, phaseTwoMs);
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
return (
<div>
<p className="mb-1 text-[10px] text-gray-500">{processed ? "Processed" : "Processing..."}</p>
<div className="flex items-center gap-2">
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-700">
<div
className="h-full rounded-full bg-white/80 ease-out"
style={{
width,
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
}}
/>
</div>
<CheckIcon
className={`h-3.5 w-3.5 shrink-0 text-accent transition-all duration-300 ${
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
/>
</div>
</div>
);
}
function DashboardMockup() {
const { ref, inView } = useInView();
const sidebarIcons = [NoteIcon, WaveIcon, GearsIcon] as const;
return (
<div ref={ref} className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
<div className="flex gap-4">
<div className="flex w-11 shrink-0 flex-col items-center gap-3 rounded-lg bg-surface py-3">
<div className="h-2 w-2 rounded-full bg-gray-500" />
{sidebarIcons.map((Icon, i) => (
<div
key={i}
className="flex h-7 w-7 items-center justify-center rounded-md border border-gray-700/50 bg-surface-dark text-gray-500"
>
<Icon className="h-4 w-4" />
</div>
))}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center justify-between">
<TypewriterText text="Multi-step configuration" />
<div className="h-4 w-8 rounded-full bg-red-500/80" />
</div>
<div className="h-7 rounded border border-gray-700 bg-surface px-2 text-xs leading-7 text-gray-500">
Title
</div>
<div className="flex flex-wrap gap-1.5">
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">genre</span>
<span className="rounded bg-gray-700 px-2 py-0.5 text-[10px] text-gray-400">audio</span>
</div>
<div className="flex h-7 items-center justify-between rounded border border-gray-700 bg-surface px-2 text-xs text-gray-500">
Category <span className="text-gray-400">Music </span>
</div>
<div className="space-y-2 pt-1">
<MockupProgressRow active={inView} initialWidth="25%" midWidth="45%" phaseOneMs={3000} phaseTwoMs={2000} />
<MockupProgressRow active={inView} initialWidth="10%" midWidth="55%" phaseOneMs={3000} phaseTwoMs={2000} />
</div>
</div>
</div>
</div>
);
}
function ProcessFlow() {
return (
<div className="relative mt-4 overflow-hidden rounded-xl border border-gray-700/50 bg-surface-dark p-5">
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
viewBox="0 0 420 240"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="flowGradH" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="rgb(239 68 68 / 0.15)" />
<stop offset="50%" stopColor="rgb(239 68 68 / 0.55)" />
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
</linearGradient>
<linearGradient id="flowGradV" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgb(239 68 68 / 0.5)" />
<stop offset="100%" stopColor="rgb(239 68 68 / 0.15)" />
</linearGradient>
</defs>
<path
d="M 58 52 C 120 52, 140 58, 168 72"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
/>
<path
d="M 58 118 C 120 108, 140 88, 168 78"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.3s" }}
/>
<path
d="M 198 75 C 250 75, 285 68, 318 68"
fill="none"
stroke="url(#flowGradH)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.6s" }}
/>
</svg>
<div className="relative z-10">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-4">
<FlowInput label="Batch Audio Files" icon="audio" />
<FlowInput label="Single Cover Image" icon="image" />
</div>
<div className="flex flex-1 flex-col items-center px-2 pt-6">
<div className="benefits-process-icon flex h-16 w-16 items-center justify-center rounded-xl border border-red-500/40 bg-surface shadow-[0_0_20px_rgba(239,68,68,0.25)]">
<Image
src="/database.png"
alt="Process"
width={40}
height={40}
className="h-10 w-10 object-contain brightness-0 invert opacity-90"
/>
</div>
<span className="mt-2 text-[10px] font-medium text-gray-400">Process</span>
</div>
<div className="flex flex-col items-center">
<div className="grid grid-cols-4 gap-1.5">
{STOCK_THUMBS.map((src) => (
<div
key={src}
className="relative h-11 w-11 overflow-hidden rounded border border-gray-700"
>
<Image
src={src}
alt=""
fill
sizes="44px"
className="scale-110 object-cover blur-[2px]"
/>
<div className="absolute inset-0 bg-black/25" />
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-white/80">
</span>
</div>
))}
</div>
<svg
className="my-2 h-10 w-6 overflow-visible"
viewBox="0 0 6 40"
aria-hidden="true"
>
<line
x1="3"
y1="2"
x2="3"
y2="38"
stroke="rgb(239 68 68 / 0.55)"
strokeWidth="1.5"
className="benefits-flow-path"
style={{ animationDelay: "0.9s" }}
/>
</svg>
<div className="flex items-center gap-3">
<YouTubeIcon className="h-9 w-9 text-red-500" />
<div>
<p className="text-xs font-bold tracking-wider text-white">YouTube</p>
<p className="text-[10px] font-semibold tracking-[0.15em] text-red-400">DIRECT UPLOAD</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function FlowInput({ label, icon }: { label: string; icon: "audio" | "image" }) {
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-gray-700 bg-surface">
{icon === "audio" ? (
<NoteIcon className="h-5 w-5 text-gray-500" />
) : (
<svg className="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
)}
</div>
<span className="max-w-[72px] text-center text-[9px] leading-tight text-gray-500">{label}</span>
</div>
);
}
function BenefitsVisual() {
return (
<div className="rounded-2xl border border-gray-700/50 bg-surface p-5 shadow-2xl shadow-black/30">
<DashboardMockup />
<ProcessFlow />
</div>
);
}
function BenefitsScrollTitle({ sectionRef }: { sectionRef: React.RefObject<HTMLElement | null> }) {
return <SectionScrollTitle sectionRef={sectionRef} title="Benefits" />;
}
export function BenefitsSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="benefits"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<BenefitsScrollTitle sectionRef={sectionRef} />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-12 text-center text-3xl font-bold text-white">Benefits</h2>
</ScrollReveal>
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-12">
<div className="flex flex-col gap-5">
{BENEFITS.map((benefit, index) => (
<ScrollReveal key={benefit.title} delay={index * 100} direction="left">
<BenefitCard title={benefit.title} desc={benefit.desc} Icon={benefit.Icon} />
</ScrollReveal>
))}
</div>
<ScrollReveal delay={200} direction="right">
<BenefitsVisual />
</ScrollReveal>
</div>
</div>
</section>
);
}
-199
View File
@@ -1,199 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { UpgradeProButton } from "@/components/UpgradeProButton";
import {
CREDIT_PRICE_CENTS,
FREE_EXTRA_CREDITS_MAX,
FREE_TOP_UP_MAX,
FREE_TOP_UP_MIN,
formatCreditPrice,
formatEuroFromCents,
} from "@/lib/credits";
type Purchase = {
id: string;
credits: number;
amountCents: number;
completedAt: string | null;
};
type Props = {
initialCredits: number;
stripeConfigured: boolean;
recentPurchases?: Purchase[];
};
export function CreditPurchasePanel({
initialCredits,
stripeConfigured,
recentPurchases = [],
}: Props) {
const router = useRouter();
const [creditsBalance, setCreditsBalance] = useState(initialCredits);
const [amount, setAmount] = useState(FREE_TOP_UP_MIN);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const room = Math.max(0, FREE_EXTRA_CREDITS_MAX - creditsBalance);
const maxBuyable = Math.min(FREE_TOP_UP_MAX, room);
const minBuyable = room < FREE_TOP_UP_MIN ? 0 : FREE_TOP_UP_MIN;
const atCap = room === 0;
const totalLabel = useMemo(() => formatCreditPrice(amount), [amount]);
const unitLabel = formatCreditPrice(1);
async function handleBuy() {
setLoading(true);
setError(null);
setMessage(null);
try {
const res = await fetch("/api/account/credits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credits: amount }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Checkout failed");
if (data.mocked) {
setCreditsBalance((c) => c + (data.granted ?? amount));
setMessage(
`Granted +${data.granted ?? amount} credits for ${formatEuroFromCents(data.amountCents ?? amount * CREDIT_PRICE_CENTS)} (dev mock).`,
);
setLoading(false);
router.refresh();
return;
}
if (!data.url) throw new Error("No checkout URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Checkout failed");
setLoading(false);
}
}
function clampAmount(value: number) {
if (maxBuyable < FREE_TOP_UP_MIN) return FREE_TOP_UP_MIN;
return Math.min(maxBuyable, Math.max(FREE_TOP_UP_MIN, value));
}
return (
<div className="mt-6 border-t border-gray-800 pt-6">
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-500">
Extra credits
</h3>
<p className="mb-4 text-sm text-gray-400">
Free plan: 10 monthly videos + up to {FREE_EXTRA_CREDITS_MAX} paid extras (
{unitLabel} each). After both are used, Pro is required.
</p>
<div className="mb-4 rounded border border-gray-800 bg-surface p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Extra credit balance
</p>
<p className="mt-1 text-2xl font-semibold text-white">
{Math.min(creditsBalance, FREE_EXTRA_CREDITS_MAX)}
<span className="text-base font-normal text-gray-400">
{" "}
/ {FREE_EXTRA_CREDITS_MAX} credits
</span>
</p>
{creditsBalance > FREE_EXTRA_CREDITS_MAX && (
<p className="mt-1 text-xs text-amber-400">
You have {creditsBalance} extras from earlier purchases; no further top-ups until
under {FREE_EXTRA_CREDITS_MAX}.
</p>
)}
</div>
{atCap ? (
<div className="space-y-2 rounded border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-100">
<p>
Free extras are capped at {FREE_EXTRA_CREDITS_MAX}. When your monthly 10 and these
extras are gone, continue with Pro (5/mo · 50 videos).
</p>
<UpgradeProButton className="font-medium text-accent underline hover:text-white" />
</div>
) : !stripeConfigured ? (
<p className="text-sm text-amber-400">
Card checkout is not configured yet (missing Stripe keys). In development you can also
use{" "}
<code className="text-gray-300">POST /api/dev/grant-credits</code>.
</p>
) : (
<div className="space-y-4">
<label className="block text-sm text-gray-300">
Credits to buy ({FREE_TOP_UP_MIN}{maxBuyable})
<div className="mt-2 flex items-center gap-3">
<input
type="range"
min={minBuyable || FREE_TOP_UP_MIN}
max={maxBuyable}
value={clampAmount(amount)}
onChange={(e) => setAmount(clampAmount(Number(e.target.value)))}
className="w-full accent-[var(--accent,#3b82f6)]"
/>
<input
type="number"
min={FREE_TOP_UP_MIN}
max={maxBuyable}
value={clampAmount(amount)}
onChange={(e) =>
setAmount(
clampAmount(Math.floor(Number(e.target.value)) || FREE_TOP_UP_MIN),
)
}
className="w-16 rounded border border-gray-700 bg-surface px-2 py-1 text-center text-white"
/>
</div>
</label>
<p className="text-sm text-gray-300">
Total:{" "}
<span className="font-semibold text-white">{totalLabel}</span>
<span className="text-gray-500">
{" "}
({unitLabel} × {clampAmount(amount)})
</span>
</p>
<button
type="button"
disabled={loading}
onClick={handleBuy}
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
{loading
? "Starting checkout…"
: `Buy ${clampAmount(amount)} credits (${totalLabel})`}
</button>
</div>
)}
{message && <p className="mt-3 text-sm text-emerald-400">{message}</p>}
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{recentPurchases.length > 0 && (
<div className="mt-6">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
Recent purchases
</h4>
<ul className="space-y-1 text-sm text-gray-400">
{recentPurchases.map((p) => (
<li key={p.id}>
+{p.credits} credits · {formatEuroFromCents(p.amountCents)}
{p.completedAt
? ` · ${new Date(p.completedAt).toLocaleDateString()}`
: ""}
</li>
))}
</ul>
</div>
)}
</div>
);
}
-160
View File
@@ -1,160 +0,0 @@
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { DOCKER_HUB_URL, GITEA_URL } from "@/lib/plans";
function DockerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Docker">
<path
fill="#2496ED"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
</svg>
);
}
function GiteaIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-label="Gitea">
<path
fill="#609926"
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
);
}
const DEPLOY_CARDS = [
{
name: "Docker Hub",
desc: "Pull the image and run Songs2VID on your own server with Docker Compose.",
Icon: DockerIcon,
cta: "View on Docker Hub",
href: DOCKER_HUB_URL,
accent: "text-[#2496ED]",
hover:
"hover:border-[#2496ED]/45 hover:bg-[#2496ED]/[0.06] hover:shadow-lg hover:shadow-[#2496ED]/20",
titleHover: "group-hover:text-[#2496ED]",
},
{
name: "Gitea",
desc: "Clone the open-source repository and deploy from source on your infrastructure.",
Icon: GiteaIcon,
cta: "View on Gitea",
href: GITEA_URL,
accent: "text-[#609926]",
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
titleHover: "group-hover:text-[#609926]",
},
] as const;
function DeployCard({
name,
desc,
Icon,
cta,
href,
accent,
hover,
titleHover,
}: (typeof DEPLOY_CARDS)[number]) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`group block rounded-xl border border-gray-700/50 bg-surface p-6 transition-all duration-300 ${hover}`}
>
<div className="mb-4 flex items-center justify-between">
<Icon className="h-8 w-8 transition-transform duration-300 group-hover:scale-110" />
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-gray-500 transition-colors duration-300 group-hover:bg-gray-700/80">
Open Source
</span>
</div>
<h3 className={`text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}>
{name}
</h3>
<p className="mt-2 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{desc}
</p>
<p className={`mt-4 text-sm font-medium ${accent} transition-all duration-300 group-hover:underline`}>
{cta}
</p>
</a>
);
}
export function DownloadSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="download"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Download" />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Download</h2>
<p className="mx-auto mb-12 max-w-2xl text-center text-gray-400">
Songs2VID is open source. Self-host on your own server with Docker or deploy from source.
</p>
</ScrollReveal>
<div className="grid items-start gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-12">
<ScrollReveal direction="left">
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold text-white">Run it yourself</h3>
<p className="mt-3 leading-relaxed text-gray-400">
Host Songs2VID on your infrastructure with full control over data, queues, and
storage. Ideal for teams and creators who want a private deployment.
</p>
</div>
<ul className="space-y-3 text-sm text-gray-400">
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Docker image published to Docker Hub
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Source code available on Gitea
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
Gitea Issues community support
</li>
<li className="flex items-start gap-3">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
PostgreSQL, Redis, FFmpeg, and worker included
</li>
</ul>
<div className="rounded-xl border border-gray-700/50 bg-surface-dark p-4">
<p className="mb-3 text-xs font-medium text-gray-500">Quick start</p>
<pre className="overflow-x-auto text-sm leading-relaxed text-gray-300">
<code>{`docker pull atakanozban/songs2vid:latest\ndocker compose up -d`}</code>
</pre>
</div>
</div>
</ScrollReveal>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
{DEPLOY_CARDS.map((card, index) => (
<ScrollReveal key={card.name} delay={index * 120} direction="right">
<DeployCard {...card} />
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
);
}
-235
View File
@@ -1,235 +0,0 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { FooterBadgeMarquee } from "@/components/FooterBadgeMarquee";
import { Logo } from "@/components/Logo";
import { prisma } from "@/lib/db";
import {
DOCKER_HUB_URL,
DOCS_URL,
GITEA_ISSUES_URL,
GITEA_URL,
SUPPORT_EMAIL,
} from "@/lib/plans";
function GiteaIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
<path
fill="currentColor"
d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"
/>
</svg>
);
}
function DockerIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" role="img" aria-hidden="true">
<path
fill="currentColor"
d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
</svg>
);
}
function FooterLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-white";
if (external || href.startsWith("#") || href.startsWith("mailto:")) {
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={className}
>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
function LegalLink({
href,
children,
external,
}: {
href: string;
children: ReactNode;
external?: boolean;
}) {
const className = "transition-colors duration-300 hover:text-gray-300";
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
const STATUS_URL = "https://status.atakanozban.com/status/2";
const DOCS_INTRO_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
const DOCS_API_URL = `${DOCS_URL.replace(/\/$/, "")}/docs/api/overview`;
export async function Footer() {
const year = new Date().getFullYear();
let initialBadges: {
id: string;
name: string;
embedHtml: string | null;
imageUrl: string | null;
linkUrl: string | null;
sortOrder: number;
}[] = [];
try {
initialBadges = 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,
},
});
} catch {
// Table may not exist yet during migrate; marquee client will retry via API.
}
return (
<footer className="mt-20 w-full border-t border-gray-800 bg-black/40 pb-8 pt-16 text-sm text-gray-400">
<div className="mx-auto max-w-6xl px-6">
<div className="grid grid-cols-1 gap-8 pb-8 md:grid-cols-12">
<div className="flex flex-col gap-4 md:col-span-5">
<Logo size="sm" beta />
<p className="max-w-sm text-gray-500">
Convert your audio tracks into stunning videos for YouTube. Beautiful, automated, and
fully open-source.
</p>
<div className="mt-2 flex items-center gap-4 text-gray-500">
<a
href={GITEA_URL}
target="_blank"
rel="noopener noreferrer"
title="Gitea"
className="transition-colors duration-300 hover:text-[#609926]"
>
<GiteaIcon className="h-5 w-5" />
</a>
<a
href={DOCKER_HUB_URL}
target="_blank"
rel="noopener noreferrer"
title="Docker Hub"
className="transition-colors duration-300 hover:text-[#2496ED]"
>
<DockerIcon className="h-5 w-5" />
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-3 md:col-span-7">
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Product
</span>
<FooterLink href="#pricing">Pricing</FooterLink>
<FooterLink href="#download">Download</FooterLink>
<FooterLink href="#benefits">Benefits</FooterLink>
<FooterLink href={DOCS_INTRO_URL} external>
Documentation
</FooterLink>
<FooterLink href={DOCS_API_URL} external>
API Reference
</FooterLink>
<FooterLink href={STATUS_URL} external>
Service Status
</FooterLink>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Open Source
</span>
<FooterLink href={GITEA_URL} external>
Gitea Instance
</FooterLink>
<FooterLink href={DOCKER_HUB_URL} external>
Docker Image
</FooterLink>
<FooterLink href={GITEA_ISSUES_URL} external>
Report a Bug
</FooterLink>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Contact
</span>
<FooterLink href={`mailto:${SUPPORT_EMAIL}`}>Support Email</FooterLink>
<span className="text-xs text-gray-600">Response within 24h for Pro users</span>
</div>
</div>
</div>
<FooterBadgeMarquee initialBadges={initialBadges} />
<div className="flex flex-col items-center gap-3 border-t border-gray-900 pt-8 text-xs text-gray-500 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 sm:justify-start">
<span>© {year} Songs2VID. All rights reserved.</span>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/privacy">Privacy Policy</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/terms">Terms of Service</LegalLink>
<span aria-hidden="true" className="hidden sm:inline">
|
</span>
<LegalLink href="/refund">Refund Policy</LegalLink>
</div>
<span className="shrink-0 text-center sm:text-right">
Made with by{" "}
<a
href="https://www.atakanozban.com"
target="_blank"
rel="noopener noreferrer"
className="transition-colors duration-300 hover:text-gray-300"
>
atakan
</a>
.
</span>
</div>
</div>
</footer>
);
}
-62
View File
@@ -1,62 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { BadgeEmbed } from "@/components/BadgeEmbed";
export type PublicFooterBadge = {
id: string;
name: string;
embedHtml: string | null;
imageUrl: string | null;
linkUrl: string | null;
sortOrder: number;
};
type Props = {
initialBadges?: PublicFooterBadge[];
};
export function FooterBadgeMarquee({ initialBadges = [] }: Props) {
const [badges, setBadges] = useState<PublicFooterBadge[]>(initialBadges);
useEffect(() => {
let cancelled = false;
fetch("/api/badges")
.then(async (res) => {
if (!res.ok) return;
const data = await res.json();
if (!cancelled && Array.isArray(data.badges)) {
setBadges(data.badges);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
if (badges.length === 0) return null;
// Duplicate track for seamless infinite scroll
const loop = [...badges, ...badges];
const durationSec = Math.max(18, badges.length * 10);
return (
<div className="badge-marquee mb-12" aria-label="Featured on">
<div
className="badge-marquee-track"
style={{ ["--badge-marquee-duration" as string]: `${durationSec}s` }}
>
{loop.map((badge, index) => (
<div
key={`${badge.id}-${index}`}
className="badge-marquee-item"
aria-hidden={index >= badges.length ? true : undefined}
>
<BadgeEmbed badge={badge} />
</div>
))}
</div>
</div>
);
}
+3 -14
View File
@@ -21,11 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
export function JobProgress({ jobId }: Props) { export function JobProgress({ jobId }: Props) {
const [job, setJob] = useState<JobResponse | null>(null); const [job, setJob] = useState<JobResponse | null>(null);
const [quota, setQuota] = useState<{ const [quota, setQuota] = useState<{
remaining: number; used: number;
limit: number;
totalAvailable: number;
plan: string;
resetsIn: string;
} | null>(null); } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -47,11 +43,7 @@ export function JobProgress({ jobId }: Props) {
const quotaData = await quotaRes.json(); const quotaData = await quotaRes.json();
if (active) if (active)
setQuota({ setQuota({
remaining: quotaData.remaining, used: quotaData.used ?? 0,
limit: quotaData.limit,
totalAvailable: quotaData.totalAvailable ?? quotaData.remaining,
plan: quotaData.plan,
resetsIn: quotaData.resetsIn,
}); });
} }
} catch (err) { } catch (err) {
@@ -91,10 +83,7 @@ export function JobProgress({ jobId }: Props) {
</div> </div>
{quota && ( {quota && (
<div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300"> <div className="rounded bg-surface-light px-4 py-2 text-sm text-gray-300">
{quota.remaining} / {quota.limit} plan · {quota.totalAvailable} total available ·{" "} {quota.used} videos created
{quota.plan === "FREE"
? `resets ${quota.resetsIn}`
: `monthly resets on ${quota.resetsIn}`}
</div> </div>
)} )}
</div> </div>
-23
View File
@@ -1,23 +0,0 @@
export function JsonLd() {
const schema = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "Songs2VID",
url: "https://songs2vid.com",
operatingSystem: "Web",
applicationCategory: "MultimediaApplication",
description: "Online tool to convert audio files into YouTube videos.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
-105
View File
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { Logo } from "@/components/Logo";
import { MobileMenuButton, MobileSidebar, sidebarLinkClass } from "@/components/MobileSidebar";
import { DOCS_URL } from "@/lib/plans";
const NAV_LINKS = [
{ href: "#benefits", label: "Benefits" },
{ href: "#download", label: "Download" },
{ href: "#pricing", label: "Pricing" },
{ href: "#support", label: "Support" },
] as const;
const DOCS_HREF = `${DOCS_URL.replace(/\/$/, "")}/docs/intro`;
function NavAnchor({
href,
label,
onNavigate,
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
}: {
href: string;
label: string;
onNavigate?: () => void;
className?: string;
}) {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const id = href.replace("#", "");
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.pushState(null, "", href);
onNavigate?.();
};
return (
<a href={href} onClick={handleClick} className={className}>
{label}
</a>
);
}
function DocsLink({
onNavigate,
className = "text-base font-medium text-gray-300 transition-colors duration-200 hover:text-white",
}: {
onNavigate?: () => void;
className?: string;
}) {
return (
<a
href={DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
className={className}
onClick={() => onNavigate?.()}
>
Docs
</a>
);
}
export function LandingNavbar() {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
<header className="group absolute left-0 right-0 top-0 z-20 bg-transparent transition-all duration-300 hover:bg-black/70 hover:backdrop-blur-md">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<Logo size="md" beta />
<div className="flex items-center gap-4">
<nav className="hidden items-center gap-10 sm:flex">
{NAV_LINKS.map(({ href, label }) => (
<NavAnchor key={href} href={href} label={label} />
))}
<DocsLink />
</nav>
<MobileMenuButton
open={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
/>
</div>
</div>
</header>
<MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} title="Menu">
{NAV_LINKS.map(({ href, label }) => (
<NavAnchor
key={href}
href={href}
label={label}
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
))}
<DocsLink
onNavigate={() => setMenuOpen(false)}
className={sidebarLinkClass()}
/>
</MobileSidebar>
</>
);
}
-19
View File
@@ -50,7 +50,6 @@ import {
type WatermarkPosition, type WatermarkPosition,
type WatermarkSettings, type WatermarkSettings,
} from "@/lib/watermark"; } from "@/lib/watermark";
import { UpgradeProButton } from "./UpgradeProButton";
type Props = { type Props = {
locked: boolean; locked: boolean;
@@ -480,26 +479,8 @@ export function LayoutStudio({
locked ? "opacity-80" : "" locked ? "opacity-80" : ""
}`} }`}
> >
{locked && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
Pro
</span>
<p className="max-w-sm text-sm text-gray-200">
Art-track layouts, blur backgrounds, typography, and watermark studio are Pro features.
</p>
<UpgradeProButton
label="Unlock video layout studio"
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
/>
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2"> <div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Video layout</h3> <h3 className="text-lg font-medium text-white">Video layout</h3>
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
Pro
</span>
</div> </div>
{/* Single live preview: art-track + watermark */} {/* Single live preview: art-track + watermark */}
+1 -1
View File
@@ -54,7 +54,7 @@ export function LegalFooter() {
<span aria-hidden="true" className="hidden sm:inline"> <span aria-hidden="true" className="hidden sm:inline">
| |
</span> </span>
<LegalLink href="/refund">Refund Policy</LegalLink> <LegalLink href="https://docs.songs2vid.com" external>Documentation</LegalLink>
<span aria-hidden="true" className="hidden sm:inline"> <span aria-hidden="true" className="hidden sm:inline">
| |
</span> </span>
+2 -5
View File
@@ -44,16 +44,13 @@ export function LegalPageLayout({ title, description, children }: Props) {
<Link href="/terms" className="hover:text-gray-300"> <Link href="/terms" className="hover:text-gray-300">
Terms Terms
</Link> </Link>
<Link href="/refund" className="hover:text-gray-300">
Refund
</Link>
<a <a
href="https://status.atakanozban.com/status/2" href="https://docs.songs2vid.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-gray-300" className="hover:text-gray-300"
> >
Service Status Documentation
</a> </a>
</div> </div>
</footer> </footer>
+4 -15
View File
@@ -3,31 +3,20 @@ import { BRAND_NAME } from "@/lib/branding";
type Props = { type Props = {
href?: string; href?: string;
size?: "sm" | "md"; size?: "sm" | "md" | "lg";
/** Small Beta mark at top-right of the wordmark (public marketing surfaces). */
beta?: boolean;
}; };
export function Logo({ href = "/", size = "md", beta = false }: Props) { export function Logo({ href = "/", size = "md" }: Props) {
const textSize = size === "sm" ? "text-xl" : "text-2xl"; const textSize = size === "sm" ? "text-xl" : size === "lg" ? "text-3xl" : "text-2xl";
const betaSize = size === "sm" ? "text-[0.5rem]" : "text-[0.55rem]";
const content = ( const content = (
<span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}> <span className={`relative inline-flex items-baseline font-bold tracking-tight ${textSize}`}>
<span className="text-white">Songs</span> <span className="text-white">Songs</span>
<span className="text-red-400">2VID</span> <span className="text-red-400">2VID</span>
{beta ? (
<span
className={`pointer-events-none absolute left-full top-0 ml-0.5 -translate-y-1/2 ${betaSize} font-bold uppercase tracking-wider text-red-400`}
aria-hidden="true"
>
Beta
</span>
) : null}
</span> </span>
); );
const label = beta ? `${BRAND_NAME} Beta` : BRAND_NAME; const label = BRAND_NAME;
if (href) { if (href) {
return ( return (
-43
View File
@@ -1,43 +0,0 @@
import Script from "next/script";
const MATOMO_URL = "https://analytics.atakanozban.com/";
const MATOMO_SITE_ID = "3";
/**
* First-party analytics (Matomo) for songs2vid.com + docs subdomain.
* Loaded once from the root layout so landing, dashboard, and legal pages share it.
*/
export function Matomo() {
return (
<>
<Script id="matomo-analytics" strategy="afterInteractive">{`
var _paq = window._paq = window._paq || [];
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
_paq.push(["setCookieDomain", "*.songs2vid.com"]);
_paq.push(["setDomains", ["*.songs2vid.com"]]);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
var u = ${JSON.stringify(MATOMO_URL)};
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", ${JSON.stringify(MATOMO_SITE_ID)}]);
var d = document, g = d.createElement("script"), s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = u + "matomo.js";
s.parentNode.insertBefore(g, s);
})();
`}</Script>
<noscript>
<p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
referrerPolicy="no-referrer-when-downgrade"
src={`${MATOMO_URL}matomo.php?idsite=${MATOMO_SITE_ID}&rec=1`}
style={{ border: 0 }}
alt=""
/>
</p>
</noscript>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
CREDIT_PRICE_CENTS,
CREDIT_PURCHASE_MAX,
CREDIT_PURCHASE_MIN,
formatCreditPrice,
} from "@/lib/credits";
export function PaygPriceCalculator() {
const [videos, setVideos] = useState(CREDIT_PURCHASE_MIN);
const total = formatCreditPrice(videos);
const unit = formatCreditPrice(1);
return (
<div className="mx-auto w-full max-w-xl rounded-2xl border border-gray-700/60 bg-surface/80 p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-wider text-amber-400">
Pay as you go
</p>
<h3 className="mt-2 text-xl font-semibold text-white sm:text-2xl">
Choose how many videos you need
</h3>
<p className="mt-2 text-sm text-gray-400">
{unit} per video · minimum {CREDIT_PURCHASE_MIN} videos (
{formatCreditPrice(CREDIT_PURCHASE_MIN)})
</p>
<div className="mt-8">
<div className="flex items-end justify-between gap-4">
<label htmlFor="payg-videos" className="text-sm text-gray-300">
Videos
</label>
<div className="flex items-center gap-2">
<input
id="payg-videos-number"
type="number"
min={CREDIT_PURCHASE_MIN}
max={CREDIT_PURCHASE_MAX}
value={videos}
onChange={(e) => {
const n = Math.floor(Number(e.target.value));
if (!Number.isFinite(n)) return;
setVideos(
Math.min(CREDIT_PURCHASE_MAX, Math.max(CREDIT_PURCHASE_MIN, n)),
);
}}
className="w-20 rounded border border-gray-600 bg-surface-dark px-3 py-2 text-center text-lg font-semibold text-white focus:border-accent focus:outline-none"
/>
</div>
</div>
<input
id="payg-videos"
type="range"
min={CREDIT_PURCHASE_MIN}
max={CREDIT_PURCHASE_MAX}
value={videos}
onChange={(e) => setVideos(Number(e.target.value))}
className="mt-4 w-full accent-amber-400"
aria-valuemin={CREDIT_PURCHASE_MIN}
aria-valuemax={CREDIT_PURCHASE_MAX}
aria-valuenow={videos}
aria-label="Number of videos"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{CREDIT_PURCHASE_MIN}</span>
<span>{CREDIT_PURCHASE_MAX}</span>
</div>
</div>
<div className="mt-8 flex flex-wrap items-end justify-between gap-4 border-t border-gray-800 pt-6">
<div>
<p className="text-xs uppercase tracking-wider text-gray-500">Total</p>
<p className="mt-1 text-3xl font-bold text-white sm:text-4xl">{total}</p>
<p className="mt-1 text-sm text-gray-400">
{videos} × {unit}{" "}
<span className="text-gray-500">
({(CREDIT_PRICE_CENTS / 100).toFixed(2)} each)
</span>
</p>
</div>
<Link
href="/dashboard/settings"
className="inline-flex rounded border border-amber-500/40 bg-amber-500/10 px-5 py-3 text-sm font-medium text-amber-300 transition-colors hover:border-amber-500/60 hover:bg-amber-500/20"
>
Buy credits
</Link>
</div>
</div>
);
}
-412
View File
@@ -1,412 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { QUOTA_REQUEST_EMAIL } from "@/lib/plans";
type ExtensionRequest = {
id: string;
status: string;
message: string;
requestedAt: string;
processedAt: string | null;
adminNote: string | null;
};
type ExtensionUsage = {
used: number;
limit: number;
remaining: number;
requests: ExtensionRequest[];
};
type Props = {
initialUsage: ExtensionUsage;
};
function openQuotaRequestMailto(reason?: string) {
const subject = encodeURIComponent("Songs2VID Quota reset / extension request");
const body = encodeURIComponent(
[
"Hi,",
"",
"I'd like to request a Pro quota reset or temporary extension.",
"",
reason?.trim() ? `Reason:\n${reason.trim()}` : "Reason: (optional add details here)",
"",
"Account email: (please keep the address you use to sign in)",
"",
"Thanks,",
].join("\n"),
);
window.location.href = `mailto:${QUOTA_REQUEST_EMAIL}?subject=${subject}&body=${body}`;
}
function formatEndDate(iso: string | null) {
if (!iso) return null;
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function PlanBillingActions({ initialUsage }: Props) {
const router = useRouter();
const [usage, setUsage] = useState(initialUsage);
const [cancelling, setCancelling] = useState(false);
const [requesting, setRequesting] = useState(false);
const [openingPortal, setOpeningPortal] = useState(false);
const [showCancelChoices, setShowCancelChoices] = useState(false);
const [cancelAtPeriodEnd, setCancelAtPeriodEnd] = useState(false);
const [endsAt, setEndsAt] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/account/cancel-subscription");
if (!res.ok) return;
const data = await res.json();
if (cancelled) return;
setCancelAtPeriodEnd(Boolean(data.cancelAtPeriodEnd));
setEndsAt(data.endsAt ?? null);
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleOpenBillingPortal() {
setOpeningPortal(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/billing-portal", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not open billing portal");
if (!data.url) throw new Error("No portal URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Could not open billing portal");
setOpeningPortal(false);
}
}
async function cancelWithMode(when: "immediate" | "period_end") {
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ when }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
setShowCancelChoices(false);
if (when === "period_end") {
setCancelAtPeriodEnd(true);
setEndsAt(data.endsAt ?? null);
const label = formatEndDate(data.endsAt ?? null);
setSuccess(
label
? `Cancellation scheduled. You keep Pro until ${label}; no further charges after that.`
: "Cancellation scheduled at the end of your current billing period. You keep Pro until then.",
);
} else {
setCancelAtPeriodEnd(false);
setEndsAt(null);
setSuccess("Subscription canceled immediately. You are now on the Free plan.");
}
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
} finally {
setCancelling(false);
}
}
async function handleResumeSubscription() {
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resume" }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to resume subscription");
setCancelAtPeriodEnd(false);
setEndsAt(data.currentPeriodEnd ?? null);
setSuccess("Subscription kept. It will renew as usual.");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resume subscription");
} finally {
setCancelling(false);
}
}
async function handleQuotaRequest() {
if (usage.remaining <= 0) return;
const reason = prompt(
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
);
if (reason === null) return;
setRequesting(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/quota-extension-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to submit request");
setUsage({
used: data.used,
limit: data.limit,
remaining: data.remaining,
requests: data.requests ?? usage.requests,
});
setSuccess(
`Request recorded (${data.used} of ${data.limit} this year). Opening your email client to contact support…`,
);
openQuotaRequestMailto(reason);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit request");
} finally {
setRequesting(false);
}
}
const hasPending = usage.requests.some((r) => r.status === "PENDING");
const endLabel = formatEndDate(endsAt);
return (
<div className="mt-4 space-y-4">
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
<p className="text-gray-300">
Extension requests this year:{" "}
<span className="font-medium text-white">
{usage.used} / {usage.limit}
</span>
{usage.remaining > 0 ? (
<span className="text-gray-500"> · {usage.remaining} remaining</span>
) : (
<span className="text-yellow-400"> · limit reached</span>
)}
</p>
</div>
{cancelAtPeriodEnd && (
<div className="rounded border border-yellow-600/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100">
<p>
Cancellation scheduled
{endLabel ? (
<>
{" "}
Pro stays active until <strong>{endLabel}</strong>
</>
) : (
<> at the end of your current billing period</>
)}
. You will not be charged again.
</p>
<button
type="button"
onClick={() => void handleResumeSubscription()}
disabled={cancelling}
className="mt-2 text-sm font-medium text-accent underline hover:text-white disabled:opacity-50"
>
{cancelling ? "Working…" : "Keep my Pro subscription"}
</button>
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={() => void handleOpenBillingPortal()}
disabled={openingPortal}
className="inline-flex items-center justify-center rounded border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-gray-800 disabled:opacity-50"
>
{openingPortal ? "Opening…" : "Manage billing & card"}
</button>
{!cancelAtPeriodEnd && (
<button
type="button"
onClick={() => {
setShowCancelChoices(true);
setError(null);
setSuccess(null);
}}
disabled={cancelling}
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
Cancel your plan
</button>
)}
<a
href={`mailto:${QUOTA_REQUEST_EMAIL}?subject=${encodeURIComponent("Songs2VID Quota reset / extension request")}`}
onClick={(e) => {
if (requesting || usage.remaining <= 0 || hasPending) {
e.preventDefault();
return;
}
e.preventDefault();
void handleQuotaRequest();
}}
aria-disabled={requesting || usage.remaining <= 0 || hasPending}
className={`inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 ${
requesting || usage.remaining <= 0 || hasPending
? "pointer-events-none cursor-not-allowed opacity-50"
: ""
}`}
>
{requesting
? "Submitting…"
: hasPending
? "Request pending…"
: "Request quota reset & extension"}
</a>
</div>
{showCancelChoices && (
<div
role="dialog"
aria-labelledby="cancel-plan-title"
className="rounded border border-yellow-600/40 bg-surface px-4 py-4"
>
<h3 id="cancel-plan-title" className="text-sm font-semibold text-white">
When should Pro end?
</h3>
<p className="mt-1 text-xs text-gray-400">
This is sent to Stripe. Choose how you want to cancel your subscription.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
disabled={cancelling}
onClick={() => void cancelWithMode("period_end")}
className="rounded border border-gray-600 bg-surface-light px-3 py-3 text-left transition-colors hover:border-accent/50 hover:bg-accent/5 disabled:opacity-50"
>
<span className="block text-sm font-medium text-white">
At end of billing period
</span>
<span className="mt-1 block text-xs text-gray-400">
Keep Pro until your paid period ends. No more renewals after that.
</span>
</button>
<button
type="button"
disabled={cancelling}
onClick={() => void cancelWithMode("immediate")}
className="rounded border border-yellow-600/50 bg-yellow-500/5 px-3 py-3 text-left transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
<span className="block text-sm font-medium text-yellow-200">
Cancel immediately
</span>
<span className="mt-1 block text-xs text-gray-400">
End Pro now and switch to Free. Access ends right away.
</span>
</button>
</div>
<button
type="button"
disabled={cancelling}
onClick={() => setShowCancelChoices(false)}
className="mt-3 text-xs text-gray-500 underline hover:text-gray-300 disabled:opacity-50"
>
{cancelling ? "Working…" : "Never mind"}
</button>
</div>
)}
{usage.requests.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Request history
</p>
<ul className="space-y-2">
{usage.requests.slice(0, 5).map((req) => (
<li
key={req.id}
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
>
<div className="flex items-center justify-between gap-2">
<span className="text-gray-300">
{new Date(req.requestedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span
className={
req.status === "APPROVED"
? "text-green-400"
: req.status === "REJECTED"
? "text-red-400"
: "text-yellow-400"
}
>
{req.status}
</span>
</div>
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-500">
Pro users may request up to 5 manual quota resets or extensions per calendar year by emailing{" "}
<a
href={`mailto:${QUOTA_REQUEST_EMAIL}`}
className="text-gray-400 underline hover:text-white"
>
{QUOTA_REQUEST_EMAIL}
</a>
. See our{" "}
<a href="/terms" className="text-gray-400 underline hover:text-white">
Terms of Service
</a>
.
</p>
</div>
);
}
-10
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { UpgradeProLink } from "./UpgradeProLink";
type Playlist = { type Playlist = {
id: string; id: string;
@@ -80,15 +79,6 @@ export function PlaylistSelect({ value, onChange, enabled }: Props) {
} }
} }
if (!enabled) {
return (
<p className="text-sm text-gray-400">
Add uploaded videos to a YouTube playlist with{" "}
<UpgradeProLink className="text-accent hover:underline" />.
</p>
);
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
-336
View File
@@ -1,336 +0,0 @@
"use client";
import Link from "next/link";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { SignInButton } from "@/components/SignInButton";
import { getVideoAttributionText } from "@/lib/branding";
import { SALES_EMAIL } from "@/lib/plans";
const FEATURE_LABELS = [
"Infrastructure",
"Limit",
"Batch mode",
"Bulk image matching",
"File type",
"Playlists",
"API support",
"ID3 tags",
"Support",
"Watermark",
] as const;
type PlanFeatures = Record<(typeof FEATURE_LABELS)[number], string>;
type InfrastructureChip = "cloud" | "self-hosted";
type PlanConfig = {
name: string;
badge: string;
price: string;
priceNote: string;
features: PlanFeatures;
infrastructureChips: InfrastructureChip[];
watermarkNote?: string;
cta:
| { type: "signin" }
| { type: "link"; label: string; href: string }
| { type: "disabled"; label: string }
| { type: "mailto"; label: string; email: string; subject: string };
highlighted: boolean;
hover: string;
accent: string;
badgeClass: string;
ctaClass?: string;
};
const PLANS: PlanConfig[] = [
{
name: "Bedroom Producer",
badge: "Free",
price: "€0",
priceNote: "/ forever",
features: {
Infrastructure: "Cloud",
Limit: "10 videos* / month · 720p · buy 115 extras (€0.25 each)",
"Batch mode": "Limited batch · up to 3 files",
"Bulk image matching": "1 static image only",
"File type": "MP3",
Playlists: "Not included",
"API support": "Not included",
"ID3 tags": "Auto-fill title & metadata from MP3 tags",
Support: "Community",
Watermark: "Optional Songs2VID badge · bottom-right",
},
infrastructureChips: ["cloud"],
watermarkNote:
`Show "${getVideoAttributionText()}" to support open-source development - opt out anytime for a clean video.`,
cta: { type: "signin" },
highlighted: false,
hover:
"hover:border-red-500/40 hover:bg-red-500/[0.04] hover:shadow-lg hover:shadow-red-500/15",
accent: "text-red-400",
badgeClass: "bg-gray-800 text-gray-400",
},
{
name: "Independent Artist",
badge: "Pro",
price: "€5",
priceNote: "/ mo",
features: {
Infrastructure: "Cloud",
Limit: "50 videos / month · 1080p",
"Batch mode": "Full batch · up to 5 files",
"Bulk image matching": "Unique image per track (PRO)",
"File type": "MP3 / WAV / FLAC",
Playlists: "Create & add uploads to YouTube playlists",
"API support": "REST API · upload, batch & playlists",
"ID3 tags": "Extended metadata support",
Support: "E-Mail",
Watermark: "Custom text/logo/fonts · art-track layouts + blur (PRO)",
},
infrastructureChips: ["cloud"],
cta: { type: "link", label: "Upgrade to Pro", href: "/dashboard/settings" },
highlighted: true,
hover:
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
accent: "text-accent",
badgeClass: "bg-accent/15 text-accent",
},
{
name: "Record Label / Studio",
badge: "Enterprise",
price: "Custom",
priceNote: "/ contact sales",
features: {
Infrastructure: "Cloud or self-hosted",
Limit: "Unlimited 4K · zero limit",
"Batch mode": "Unlimited synchronized batch processing",
"Bulk image matching": "Unique image per track",
"File type": "WAV / FLAC / lossless",
Playlists: "Org-wide playlist workflows",
"API support": "Full API access · custom integrations & SLAs",
"ID3 tags": "Full metadata · custom mapping",
Support: "Top-priority**",
Watermark: "Custom branding · position studio",
},
infrastructureChips: ["cloud", "self-hosted"],
cta: {
type: "mailto",
label: "Contact Sales",
email: SALES_EMAIL,
subject: "Songs2VID Enterprise Inquiry",
},
highlighted: false,
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
accent: "text-[#609926]",
badgeClass: "bg-[#609926]/15 text-[#609926]",
ctaClass:
"border-[#609926]/40 bg-[#609926]/10 text-[#609926] hover:border-[#609926]/60 hover:bg-[#609926]/20",
},
];
function FeatureValue({ value }: { value: string }) {
return <span className="text-sm text-gray-200">{value}</span>;
}
function CloudIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path
d="M7.5 18.5h9.75a4.25 4.25 0 0 0 .55-8.46A5.75 5.75 0 0 0 6.9 8.8 4.5 4.5 0 0 0 7.5 18.5Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ServerIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="4" y="5" width="16" height="6" rx="2" />
<rect x="4" y="13" width="16" height="6" rx="2" />
<path d="M7.5 8h.01M7.5 16h.01" strokeLinecap="round" />
</svg>
);
}
function InfrastructureChips({
chips,
}: {
chips: InfrastructureChip[];
}) {
return (
<div className="mt-2 flex flex-wrap items-center gap-2">
{chips.includes("cloud") && (
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
<CloudIcon className="h-4 w-4 text-gray-300" />
Cloud
</span>
)}
{chips.includes("self-hosted") && (
<span className="inline-flex items-center gap-2 rounded-lg border border-gray-700/60 bg-surface-dark/40 px-3 py-2 text-sm text-gray-200">
<ServerIcon className="h-4 w-4 text-gray-300" />
Self-hosted
</span>
)}
</div>
);
}
function PricingCard({ plan }: { plan: PlanConfig }) {
const {
name,
badge,
price,
priceNote,
features,
watermarkNote,
infrastructureChips,
cta,
highlighted,
hover,
accent,
badgeClass,
ctaClass,
} = plan;
return (
<div
className={`group relative flex h-full flex-col rounded-2xl border bg-surface p-6 transition-all duration-300 sm:p-7 ${
highlighted
? "border-accent/40 shadow-[0_0_32px_rgba(74,158,255,0.12)]"
: "border-gray-700/50"
} ${hover}`}
>
{highlighted && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-accent px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white">
Most popular
</span>
)}
<div className="mb-6">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{name}</h3>
<span
className={`shrink-0 rounded px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${badgeClass}`}
>
{badge}
</span>
</div>
<div className="flex items-end gap-1">
<span className={`text-4xl font-bold ${accent}`}>{price}</span>
<span className="mb-1 text-sm text-gray-500">{priceNote}</span>
</div>
</div>
<ul className="mb-6 flex-1 space-y-4 border-t border-gray-700/50 pt-6">
{FEATURE_LABELS.map((label) => (
<li key={label}>
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
{label === "Infrastructure" ? (
<InfrastructureChips chips={infrastructureChips} />
) : (
<FeatureValue value={features[label]} />
)}
{label === "Watermark" && watermarkNote && (
<p className="mt-1.5 rounded-lg border border-gray-700/60 bg-surface-dark/80 px-3 py-2 text-xs leading-relaxed text-gray-400">
{watermarkNote}
</p>
)}
</li>
))}
</ul>
<div className="mt-auto">
{cta.type === "signin" && <SignInButton fullWidth />}
{cta.type === "link" && (
<Link
href={cta.href}
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? "border-gray-600 text-white hover:border-gray-400"}`}
>
{cta.label}
</Link>
)}
{cta.type === "disabled" && (
<button
type="button"
disabled
className="w-full cursor-not-allowed rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm font-medium text-gray-500"
>
{cta.label}
</button>
)}
{cta.type === "mailto" && (
<a
href={`mailto:${cta.email}?subject=${encodeURIComponent(cta.subject)}`}
className={`inline-flex w-full items-center justify-center rounded border px-4 py-3 text-sm font-medium transition-all duration-300 ${ctaClass ?? ""}`}
>
{cta.label}
</a>
)}
</div>
</div>
);
}
export function PricingSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="pricing"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 py-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Pricing" />
<div className="relative z-10 mx-auto max-w-7xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Pricing</h2>
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
Free with optional extras, Pro at 5/month, or Enterprise for studios.
</p>
</ScrollReveal>
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3 xl:gap-6">
{PLANS.map((plan, index) => (
<ScrollReveal key={plan.name} delay={index * 100}>
<PricingCard plan={plan} />
</ScrollReveal>
))}
</div>
<ScrollReveal delay={360}>
<p className="mx-auto mt-10 max-w-2xl text-center text-xs text-gray-500">
*Free includes 10 videos/month; buy 115 extras at 0.25 each (max 15 extras). After both
are used, Pro (5/mo · 50 videos) is required. Total balance capped at 30. Deduction
order: monthly first, then extras.
</p>
<p className="mx-auto mt-4 max-w-2xl text-center text-xs text-gray-500">
**Technical support is strictly reserved for Managed Cloud and paid Professional Setup
agreements; independent self-hosted deployments are community-supported.
</p>
</ScrollReveal>
</div>
</section>
);
}
+2 -4
View File
@@ -1,17 +1,15 @@
"use client"; "use client";
import { Plan } from "@prisma/client";
import { getResolutionsForPlan } from "@/lib/plans"; import { getResolutionsForPlan } from "@/lib/plans";
type Props = { type Props = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
disabled?: boolean; disabled?: boolean;
plan?: Plan;
}; };
export function ResolutionSelect({ value, onChange, disabled, plan = "FREE" }: Props) { export function ResolutionSelect({ value, onChange, disabled }: Props) {
const resolutions = getResolutionsForPlan(plan); const resolutions = getResolutionsForPlan();
return ( return (
<select <select
-58
View File
@@ -1,58 +0,0 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
type Direction = "up" | "left" | "right";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
direction?: Direction;
};
const OFFSET: Record<Direction, string> = {
up: "translate-y-10",
left: "-translate-x-10",
right: "translate-x-10",
};
export function ScrollReveal({
children,
className = "",
delay = 0,
direction = "up",
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${className} ${
visible ? "translate-x-0 translate-y-0 opacity-100" : `opacity-0 ${OFFSET[direction]}`
}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
-46
View File
@@ -1,46 +0,0 @@
"use client";
import { useEffect, useState, type RefObject } from "react";
type Props = {
sectionRef: RefObject<HTMLElement | null>;
title: string;
offset?: number;
};
export function SectionScrollTitle({ sectionRef, title, offset = 350 }: Props) {
const [offsetX, setOffsetX] = useState(0);
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
const update = () => {
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const progress = Math.min(
1,
Math.max(0, (viewportHeight - rect.top) / (viewportHeight + rect.height * 0.5)),
);
setOffsetX(progress * offset);
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [sectionRef, offset]);
return (
<span
className="pointer-events-none absolute bottom-0 left-1/2 z-0 select-none whitespace-nowrap text-7xl font-bold leading-none text-white/[0.04] sm:text-8xl lg:text-9xl"
style={{ transform: `translateX(calc(-50% + ${offsetX}px))` }}
aria-hidden="true"
>
{title}
</span>
);
}
-234
View File
@@ -1,234 +0,0 @@
"use client";
import { ScrollReveal } from "@/components/ScrollReveal";
import { useInView } from "@/hooks/useInView";
import { useMockupProgress } from "@/hooks/useMockupProgress";
function LayersIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
);
}
function SlidersIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" />
<circle cx="4" cy="14" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="20" cy="16" r="2" />
</svg>
);
}
function CloudUploadIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 16V8M12 8l-3 3M12 8l3 3" />
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.9-1A4 4 0 1 1 17 18H7z" />
</svg>
);
}
function ImageIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
);
}
function AudioIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
const STEPS = [
{
number: 1,
title: "Single/Batch creation",
desc: "One image, many audios, each becomes a separate video.",
Icon: LayersIcon,
},
{
number: 2,
title: "Per-video settings",
desc: "Title, tags, privacy, and category for every upload.",
Icon: SlidersIcon,
},
{
number: 3,
title: "Direct upload",
desc: "Connect YouTube once and publish automatically.",
Icon: CloudUploadIcon,
},
] as const;
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function AppMockup() {
const phaseOneMs = 3000;
const phaseTwoMs = 2000;
const initialWidth = "20%";
const midWidth = "45%";
const { ref, inView } = useInView();
const { phase, processed, transitionMs } = useMockupProgress(inView, phaseOneMs, phaseTwoMs);
const width = phase === 0 ? initialWidth : phase === 1 ? midWidth : "100%";
return (
<div
ref={ref}
className="mt-10 overflow-hidden rounded-2xl border border-gray-700/60 bg-surface-dark shadow-2xl shadow-black/40"
>
<div className="flex items-center gap-2 border-b border-gray-700/50 px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
<span className="h-2.5 w-2.5 rounded-full bg-gray-600" />
</div>
<div className="p-6">
<div className="rounded-xl border border-gray-700/50 bg-surface p-6">
<div className="grid grid-cols-2 gap-4">
<div className="mockup-upload-box mockup-upload-box-image flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
<ImageIcon className="mockup-icon-image mb-3 h-8 w-8 text-gray-500" />
<span className="text-xs font-semibold tracking-widest text-gray-500">IMAGE</span>
</div>
<div className="mockup-upload-box mockup-upload-box-audio flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-600 bg-surface-dark px-4 py-8">
<AudioIcon className="mockup-icon-audio mb-3 h-8 w-8 text-gray-500" />
<span className="text-xs font-semibold tracking-widest text-gray-500">AUDIO</span>
</div>
</div>
<p className="mt-6 text-sm text-gray-400">
{processed ? (
"Processed"
) : (
<>
Processing
<span className="mockup-dot-1">.</span>
<span className="mockup-dot-2">.</span>
<span className="mockup-dot-3">.</span>
</>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<div className="relative h-1 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-700">
<div
className="relative h-full rounded-full bg-white/90 ease-out"
style={{
width,
transition: phase >= 1 ? `width ${transitionMs}ms ease-out` : "none",
}}
>
{!processed && (
<span className="mockup-progress-shimmer absolute inset-y-0 w-1/2 rounded-full bg-gradient-to-r from-transparent via-white/50 to-transparent" />
)}
</div>
</div>
<CheckIcon
className={`h-4 w-4 shrink-0 text-accent transition-all duration-300 ${
processed ? "scale-100 opacity-100" : "scale-75 opacity-0"
}`}
/>
</div>
</div>
</div>
</div>
);
}
function StepCard({
number,
title,
desc,
Icon,
}: {
number: number;
title: string;
desc: string;
Icon: typeof LayersIcon;
}) {
return (
<div className="group relative overflow-hidden rounded-2xl border border-gray-700/50 bg-surface transition-all duration-300 hover:border-gray-500 hover:bg-surface-light hover:shadow-xl hover:shadow-black/30">
<span
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 select-none text-[7.5rem] font-bold leading-none text-gray-600/20 transition-all duration-500 ease-out group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:scale-[1.18] group-hover:text-red-500/25 sm:text-[8.5rem]"
aria-hidden="true"
>
{number}
</span>
<div className="relative flex items-center gap-6 px-8 py-9 pl-24 sm:pl-28">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-gray-600/50 bg-surface-dark text-gray-400 transition-all duration-300 group-hover:border-red-500/30 group-hover:text-red-400">
<Icon className="h-7 w-7" />
</div>
<div>
<h3 className="text-lg font-semibold text-white transition-colors duration-300 group-hover:text-red-400 sm:text-xl">
{title}
</h3>
<p className="mt-2 text-base leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{desc}
</p>
</div>
</div>
</div>
);
}
export function StepsSection() {
return (
<section className="mx-auto max-w-6xl scroll-mt-20 px-6 pb-24 pt-32">
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
<ScrollReveal direction="left">
<div>
<h2 className="text-2xl font-bold leading-snug text-white sm:text-3xl">
Upload your content in 3 simple steps:
</h2>
<p className="mt-6 text-base leading-relaxed text-gray-400">
From a single track to a full batch, Songs2VID walks you through creating and
publishing videos to YouTube without touching a video editor.
</p>
<AppMockup />
</div>
</ScrollReveal>
<div className="flex flex-col gap-7">
{STEPS.map((step, index) => (
<ScrollReveal key={step.number} delay={index * 120} direction="right">
<StepCard
number={step.number}
title={step.title}
desc={step.desc}
Icon={step.Icon}
/>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
-116
View File
@@ -1,116 +0,0 @@
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/ScrollReveal";
import { SectionScrollTitle } from "@/components/SectionScrollTitle";
import { GITEA_ISSUES_URL, SUPPORT_EMAIL } from "@/lib/plans";
type SupportCardProps = {
title: string;
description: string;
href: string;
cta: string;
external?: boolean;
hover: string;
titleHover: string;
linkClass: string;
linkHover: string;
};
function SupportCard({
title,
description,
href,
cta,
external,
hover,
titleHover,
linkClass,
linkHover,
}: SupportCardProps) {
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={`group block rounded-xl border border-gray-700/50 bg-surface/80 p-6 backdrop-blur-sm transition-all duration-300 ${hover}`}
>
<h3
className={`mb-2 text-lg font-semibold text-white transition-colors duration-300 ${titleHover}`}
>
{title}
</h3>
<p className="mb-4 text-sm leading-relaxed text-gray-400 transition-colors duration-300 group-hover:text-gray-300">
{description}
</p>
<span
className={`inline-flex text-sm font-medium transition-all duration-300 group-hover:underline ${linkClass} ${linkHover}`}
>
{cta}
</span>
</a>
);
}
const SUPPORT_CARDS: SupportCardProps[] = [
{
title: "🛠️ Community & Self-Hosting",
description:
"Found a bug, want to request a feature, or need help setting up your Docker instance? Open an issue on our self-hosted Gitea.",
href: GITEA_ISSUES_URL,
cta: "Open Gitea Issues",
external: true,
hover:
"hover:border-[#609926]/45 hover:bg-[#609926]/[0.06] hover:shadow-lg hover:shadow-[#609926]/20",
titleHover: "group-hover:text-[#609926]",
linkClass: "text-[#609926]",
linkHover: "group-hover:text-[#7ab33a]",
},
{
title: "📩 Billing & Premium Support",
description:
"Have questions about your Pro subscription, custom limits, or Enterprise inquiries? Drop us an email.",
href: `mailto:${SUPPORT_EMAIL}`,
cta: SUPPORT_EMAIL,
hover:
"hover:border-accent/50 hover:bg-accent/[0.06] hover:shadow-lg hover:shadow-accent/20",
titleHover: "group-hover:text-accent",
linkClass: "text-accent",
linkHover: "group-hover:text-accent-hover",
},
];
export function SupportSection() {
const sectionRef = useRef<HTMLElement>(null);
return (
<section
ref={sectionRef}
id="support"
className="relative scroll-mt-24 overflow-x-visible overflow-y-hidden px-6 pb-24 pt-24"
>
<SectionScrollTitle sectionRef={sectionRef} title="Support" />
<div className="relative z-10 mx-auto max-w-4xl">
<ScrollReveal>
<h2 className="mb-4 text-center text-3xl font-bold text-white">Support</h2>
<p className="mx-auto mb-10 max-w-2xl text-center text-gray-400">
Community help for self-hosters, or reach us directly for billing and premium support.
</p>
</ScrollReveal>
<div className="mx-auto mt-10 grid grid-cols-1 gap-8 text-left md:grid-cols-2">
{SUPPORT_CARDS.map((card, index) => (
<ScrollReveal
key={card.title}
direction={index === 0 ? "left" : "right"}
delay={index * 120}
>
<SupportCard {...card} />
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
type Props = {
className?: string;
label?: string;
};
/** Starts Pro Checkout (€5/mo) or applies a dev mock upgrade. */
export function UpgradeProButton({
className = "text-accent hover:underline",
label = "Upgrade to Pro",
}: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleUpgrade() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/account/subscribe", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Upgrade failed");
if (data.mocked) {
router.refresh();
return;
}
if (!data.url) throw new Error("No checkout URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Upgrade failed");
setLoading(false);
}
}
return (
<span className="inline">
<button
type="button"
onClick={handleUpgrade}
disabled={loading}
className={className}
>
{loading ? "Starting…" : label}
</button>
{error && <span className="ml-2 text-sm text-red-400">{error}</span>}
</span>
);
}
-34
View File
@@ -1,34 +0,0 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { PRO_UPGRADE_REQUIRED_SUFFIX } from "@/lib/credits";
import { UPGRADE_URL } from "@/lib/plans";
import { UpgradeProButton } from "@/components/UpgradeProButton";
type Props = {
className?: string;
children?: ReactNode;
};
export function UpgradeProLink({ className = "text-accent hover:underline", children }: Props) {
return (
<Link href={UPGRADE_URL} className={className}>
{children ?? "Upgrade to Pro"}
</Link>
);
}
export function QuotaErrorMessage({ message }: { message: string }) {
if (message.endsWith(PRO_UPGRADE_REQUIRED_SUFFIX)) {
return (
<>
{message.slice(0, -PRO_UPGRADE_REQUIRED_SUFFIX.length)}{" "}
<UpgradeProButton
className="font-medium text-red-200 underline hover:text-white"
label="Upload up to 50 videos with Pro!"
/>
</>
);
}
return <>{message}</>;
}
+22 -276
View File
@@ -2,9 +2,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Plan, Privacy } from "@prisma/client"; import { Privacy } from "@prisma/client";
import { filenameWithoutExtension } from "@/lib/constants"; import { filenameWithoutExtension } from "@/lib/constants";
import { getVideoAttributionText } from "@/lib/branding";
import { audioTagsToMetadata } from "@/lib/audio-tags"; import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles"; import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout"; import { SONG_TITLE_MAX } from "@/lib/layout";
@@ -16,10 +15,6 @@ import { LayoutStudio } from "./LayoutStudio";
import { PlaylistSelect } from "./PlaylistSelect"; import { PlaylistSelect } from "./PlaylistSelect";
import { PrivacyToggle } from "./PrivacyToggle"; import { PrivacyToggle } from "./PrivacyToggle";
import { ResolutionSelect } from "./ResolutionSelect"; import { ResolutionSelect } from "./ResolutionSelect";
import { QuotaErrorMessage, UpgradeProLink } from "./UpgradeProLink";
import { UpgradeProButton } from "./UpgradeProButton";
import { WatermarkPaywallModal } from "./WatermarkPaywallModal";
import { FREE_UNWATERMARKED_VIDEO_LIMIT } from "@/lib/watermark-policy";
const UPLOAD_CONCURRENCY = 4; const UPLOAD_CONCURRENCY = 4;
@@ -71,55 +66,17 @@ export function UploadForm() {
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT }); const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{ const [quota, setQuota] = useState<{
remaining: number;
limit: number;
plan: Plan;
maxBatchSize: number; maxBatchSize: number;
resetsIn: string;
videoCredits: number;
extraCredits: number;
totalAvailable: number;
used: number; used: number;
selfHosted?: boolean;
customWatermark?: boolean;
perItemImages?: boolean;
artTrackLayouts?: boolean;
subscriptionStatus?: "free" | "pro";
createdVideoCount?: number;
watermarkFreeRemaining?: number | null;
} | null>(null); } | null>(null);
const [showWatermarkPaywall, setShowWatermarkPaywall] = useState(false);
const [watermarkDefaultsApplied, setWatermarkDefaultsApplied] = useState(false);
const isPro = Boolean(quota?.selfHosted || quota?.plan === "PREMIUM");
const canPerItemImages = Boolean(quota?.perItemImages || isPro);
const canCustomWatermark = Boolean(quota?.customWatermark || isPro);
const canArtTrackLayouts = Boolean(quota?.artTrackLayouts || isPro);
const loadQuota = useCallback(async () => { const loadQuota = useCallback(async () => {
const res = await fetch("/api/quota"); const res = await fetch("/api/quota");
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setQuota({ setQuota({
remaining: data.remaining,
limit: data.limit,
plan: data.plan,
maxBatchSize: data.maxBatchSize, maxBatchSize: data.maxBatchSize,
resetsIn: data.resetsIn,
videoCredits: data.videoCredits ?? data.extraCredits ?? 0,
extraCredits: data.extraCredits ?? data.videoCredits ?? 0,
totalAvailable: data.totalAvailable ?? data.remaining,
used: data.used ?? 0, used: data.used ?? 0,
selfHosted: Boolean(data.selfHosted),
customWatermark: Boolean(data.customWatermark),
perItemImages: Boolean(data.perItemImages),
artTrackLayouts: Boolean(data.artTrackLayouts),
subscriptionStatus: data.subscriptionStatus === "pro" ? "pro" : "free",
createdVideoCount: typeof data.createdVideoCount === "number" ? data.createdVideoCount : 0,
watermarkFreeRemaining:
data.watermarkFreeRemaining === null || data.watermarkFreeRemaining === undefined
? null
: Number(data.watermarkFreeRemaining),
}); });
} }
}, []); }, []);
@@ -128,21 +85,6 @@ export function UploadForm() {
loadQuota(); loadQuota();
}, [loadQuota]); }, [loadQuota]);
// Align free-tier watermark default with remaining watermark-free slots (once).
useEffect(() => {
if (!quota || watermarkDefaultsApplied) return;
if (quota.selfHosted || quota.plan === "PREMIUM") {
setWatermarkDefaultsApplied(true);
return;
}
const remaining = quota.watermarkFreeRemaining ?? 0;
setJobWatermark({
...DEFAULT_WATERMARK,
mode: remaining > 0 ? "none" : "default",
});
setWatermarkDefaultsApplied(true);
}, [quota, watermarkDefaultsApplied]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
@@ -257,10 +199,6 @@ export function UploadForm() {
} }
async function handleItemImageChange(itemId: string, file: File | null) { async function handleItemImageChange(itemId: string, file: File | null) {
if (!canPerItemImages) {
setError("Matching a unique image per audio requires Pro.");
return;
}
if (!file) return; if (!file) return;
setError(null); setError(null);
const preview = URL.createObjectURL(file); const preview = URL.createObjectURL(file);
@@ -354,9 +292,9 @@ export function UploadForm() {
if (!files.length) return; if (!files.length) return;
setError(null); setError(null);
const maxBatch = quota?.maxBatchSize ?? 3; const maxBatch = quota?.maxBatchSize ?? 100;
if (audioItems.length + files.length > maxBatch) { if (audioItems.length + files.length > maxBatch) {
setError(`Your plan allows up to ${maxBatch} audio files per batch.`); setError(`You can upload up to ${maxBatch} audio files per batch.`);
e.target.value = ""; e.target.value = "";
return; return;
} }
@@ -405,7 +343,6 @@ export function UploadForm() {
songTitle: a.metadata.songTitle, songTitle: a.metadata.songTitle,
artist: a.metadata.artist, artist: a.metadata.artist,
}, },
isPro ? Plan.PREMIUM : Plan.FREE,
), ),
), ),
) && ) &&
@@ -418,24 +355,7 @@ export function UploadForm() {
setError(null); setError(null);
try { try {
const forcedWatermark = const effectiveWatermark: WatermarkSettings = jobWatermark;
!isPro &&
!quota?.selfHosted &&
(quota?.createdVideoCount ?? 0) >= FREE_UNWATERMARKED_VIDEO_LIMIT;
const effectiveWatermark: WatermarkSettings = forcedWatermark
? { ...DEFAULT_WATERMARK, mode: "default" }
: canCustomWatermark
? jobWatermark
: {
mode: jobWatermark.mode === "none" ? "none" : "default",
position: "bottom-right",
offsetX: 20,
offsetY: 20,
text: null,
logoPath: null,
fontKey: "system",
fontPath: null,
};
const res = await fetch("/api/jobs", { const res = await fetch("/api/jobs", {
method: "POST", method: "POST",
@@ -447,10 +367,10 @@ export function UploadForm() {
audioFilename: item.file.name, audioFilename: item.file.name,
metadata: { metadata: {
...item.metadata, ...item.metadata,
imagePath: canPerItemImages ? item.itemImagePath || item.metadata.imagePath || null : null, imagePath: item.itemImagePath || item.metadata.imagePath || null,
includeWatermark: effectiveWatermark.mode !== "none", includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark, watermark: effectiveWatermark,
layout: canArtTrackLayouts ? jobLayout : { ...DEFAULT_LAYOUT }, layout: jobLayout,
}, },
})), })),
}), }),
@@ -458,9 +378,6 @@ export function UploadForm() {
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
if (data.code === "PREMIUM_REQUIRED") {
throw new Error(data.error || "This feature requires Pro.");
}
throw new Error(data.error || "Failed to create job"); throw new Error(data.error || "Failed to create job");
} }
@@ -475,79 +392,20 @@ export function UploadForm() {
e.preventDefault(); e.preventDefault();
if (!canSubmit || !imagePath) return; if (!canSubmit || !imagePath) return;
const needsPaywall =
!isPro &&
!quota?.selfHosted &&
(quota?.createdVideoCount ?? 0) >= FREE_UNWATERMARKED_VIDEO_LIMIT;
if (needsPaywall) {
setShowWatermarkPaywall(true);
return;
}
await submitJob(); await submitJob();
} }
function watermarkStatusLabel(): string | null {
if (!quota || quota.selfHosted) return null;
if (quota.plan === "PREMIUM" || quota.subscriptionStatus === "pro") {
return "Pro Member (No Watermark)";
}
const remaining = quota.watermarkFreeRemaining ?? 0;
if (remaining > 0) {
return `Free Tier: ${remaining}/${FREE_UNWATERMARKED_VIDEO_LIMIT} Watermark-Free Renders Left`;
}
return "Free Tier (Watermark Active)";
}
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<WatermarkPaywallModal
open={showWatermarkPaywall}
onClose={() => setShowWatermarkPaywall(false)}
onContinueWithWatermark={() => {
setShowWatermarkPaywall(false);
void submitJob();
}}
/>
{quota && ( {quota && (
<div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300"> <div className="rounded border border-gray-700 bg-surface-light px-4 py-3 text-sm text-gray-300">
{watermarkStatusLabel() ? ( Self-hosted · {quota.used} videos created · up to {quota.maxBatchSize} files per batch
<p className="mb-2 font-medium text-white">{watermarkStatusLabel()}</p>
) : null}
{quota.selfHosted ? (
<>
Self-hosted · {quota.used} videos processed · up to {quota.maxBatchSize} files per
batch
</>
) : (
<>
{quota.remaining} of {quota.limit} plan videos remaining
{quota.extraCredits > 0
? ` · ${quota.extraCredits} extra credits (${quota.totalAvailable} total available)`
: ""}{" "}
·{" "}
{quota.plan === "FREE"
? `resets in ${quota.resetsIn}`
: `monthly quota resets on ${quota.resetsIn}`}{" "}
· up to {quota.maxBatchSize} files per batch
{quota.plan === "FREE" ? (
<>
{" · "}
<a href="/dashboard/settings" className="text-accent hover:underline">
Buy 115 credits
</a>
</>
) : null}
</>
)}
</div> </div>
)} )}
{error && ( {error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300"> <div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
<QuotaErrorMessage message={error} /> {error}
</div> </div>
)} )}
@@ -570,9 +428,7 @@ export function UploadForm() {
</span> </span>
</div> </div>
<p className="mt-2 text-xs text-gray-500"> <p className="mt-2 text-xs text-gray-500">
{canPerItemImages Shared cover for all tracks. You can override it per audio below.
? "Shared cover for all tracks. Optionally override per audio below (Pro)."
: "Free plan: one static cover for the whole batch."}
</p> </p>
</div> </div>
@@ -580,11 +436,7 @@ export function UploadForm() {
<label className="mb-2 block text-sm text-gray-400">Audio files</label> <label className="mb-2 block text-sm text-gray-400">Audio files</label>
<input <input
type="file" type="file"
accept={ accept="audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
quota?.selfHosted || quota?.plan === "PREMIUM"
? "audio/mpeg,audio/wav,audio/flac,.mp3,.wav,.flac"
: "audio/mpeg,.mp3"
}
multiple multiple
onChange={handleAudioChange} onChange={handleAudioChange}
className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white" className="block w-full text-sm text-gray-300 file:mr-4 file:rounded file:border-0 file:bg-accent file:px-4 file:py-2 file:text-sm file:text-white"
@@ -606,14 +458,14 @@ export function UploadForm() {
<h2 className="text-lg font-medium text-white">Video details (per audio)</h2> <h2 className="text-lg font-medium text-white">Video details (per audio)</h2>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Each audio file gets its own metadata. Video title is used for YouTube; song title and Each audio file gets its own metadata. Video title is used for YouTube; song title and
artist appear in Pro art-track layouts. artist appear in art-track layouts.
</p> </p>
<div className="rounded-lg border border-gray-700 bg-surface p-6"> <div className="rounded-lg border border-gray-700 bg-surface p-6">
<PlaylistSelect <PlaylistSelect
value={playlistId} value={playlistId}
onChange={handlePlaylistChange} onChange={handlePlaylistChange}
enabled={Boolean(quota?.selfHosted || quota?.plan === "PREMIUM")} enabled
/> />
</div> </div>
@@ -652,39 +504,29 @@ export function UploadForm() {
/> />
</Field> </Field>
<ProLockedField <Field label="Song title (on-video)">
label="Song title (on-video)"
locked={!isPro}
hint="Pro unlocks custom song title burned into art-track layouts."
>
<input <input
type="text" type="text"
value={item.metadata.songTitle ?? ""} value={item.metadata.songTitle ?? ""}
onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })} onChange={(e) => updateItemMetadata(item.id, { songTitle: e.target.value })}
className="input-field disabled:cursor-not-allowed disabled:opacity-60" className="input-field"
disabled={!isPro} placeholder="Shown in art-track layout"
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
maxLength={SONG_TITLE_MAX} maxLength={SONG_TITLE_MAX}
/> />
</ProLockedField> </Field>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<ProLockedField <Field label="Artist (on-video)">
label="Artist (on-video)"
locked={!isPro}
hint="Pro unlocks custom artist line in art-track layouts."
>
<input <input
type="text" type="text"
value={item.metadata.artist ?? ""} value={item.metadata.artist ?? ""}
onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })} onChange={(e) => updateItemMetadata(item.id, { artist: e.target.value })}
className="input-field disabled:cursor-not-allowed disabled:opacity-60" className="input-field"
disabled={!isPro} placeholder="Shown in art-track layout"
placeholder={isPro ? "Shown in art-track layout" : "Pro feature"}
maxLength={80} maxLength={80}
/> />
</ProLockedField> </Field>
<Field label="Category"> <Field label="Category">
<CategorySelect <CategorySelect
value={item.metadata.categoryId} value={item.metadata.categoryId}
@@ -723,30 +565,14 @@ export function UploadForm() {
<ResolutionSelect <ResolutionSelect
value={item.metadata.resolution} value={item.metadata.resolution}
onChange={(v) => updateItemMetadata(item.id, { resolution: v })} onChange={(v) => updateItemMetadata(item.id, { resolution: v })}
plan={quota?.plan}
/> />
</Field> </Field>
<div className="relative rounded border border-gray-800 bg-surface-light/40 p-3"> <div className="relative rounded border border-gray-800 bg-surface-light/40 p-3">
{!canPerItemImages && ( <Field label="Cover for this track (optional)">
<div className="mb-2 flex items-center gap-2">
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent">
Pro
</span>
<span className="text-xs text-gray-500">Unique image per track</span>
</div>
)}
<Field
label={
canPerItemImages
? "Cover for this track (optional)"
: "Cover for this track (Pro)"
}
>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
disabled={!canPerItemImages}
onChange={(e) => { onChange={(e) => {
const f = e.target.files?.[0] ?? null; const f = e.target.files?.[0] ?? null;
void handleItemImageChange(item.id, f); void handleItemImageChange(item.id, f);
@@ -760,11 +586,6 @@ export function UploadForm() {
{item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}`} {item.itemImagePath ? `Using ${item.itemImageName}` : `Uploading ${item.itemImageName}`}
</p> </p>
)} )}
{!canPerItemImages && (
<p className="mt-2 text-xs text-gray-500">
<UpgradeProButton label="Upgrade for bulk unique image matching" />
</p>
)}
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -788,32 +609,6 @@ export function UploadForm() {
checked={item.metadata.creativeCommons} checked={item.metadata.creativeCommons}
onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })} onChange={(v) => updateItemMetadata(item.id, { creativeCommons: v })}
/> />
{!canCustomWatermark && (
<Checkbox
label={
!isPro &&
(quota?.watermarkFreeRemaining ?? 0) <= 0 &&
!quota?.selfHosted
? "Songs2VID watermark (required on Free after 2 clean renders)"
: `Show '${getVideoAttributionText()}' watermark (support open-source)`
}
checked={jobWatermark.mode !== "none"}
onChange={(v) => {
if (
!v &&
!isPro &&
!quota?.selfHosted &&
(quota?.watermarkFreeRemaining ?? 0) <= 0
) {
return;
}
applyWatermarkToAll({
...DEFAULT_WATERMARK,
mode: v ? "default" : "none",
});
}}
/>
)}
</div> </div>
</div> </div>
))} ))}
@@ -821,7 +616,7 @@ export function UploadForm() {
)} )}
<LayoutStudio <LayoutStudio
locked={!canArtTrackLayouts && !canCustomWatermark} locked={false}
previewImageUrl={ previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
} }
@@ -841,21 +636,6 @@ export function UploadForm() {
logoPreviewUrl={logoPreviewUrl} logoPreviewUrl={logoPreviewUrl}
/> />
{quota && !quota.selfHosted && quota.plan === "FREE" && (
<>
<p className="text-sm text-gray-400">
Free plan: first {FREE_UNWATERMARKED_VIDEO_LIMIT} successful videos are watermark-free;
later renders include a Songs2VID watermark (bottom-right). One static cover image per
batch.
</p>
<p className="text-sm text-gray-400">
<UpgradeProLink className="text-accent hover:underline" /> for unwatermarked videos,
custom branding, typography, blurred art-track layouts, unique image matching, 1080p, and
lossless audio.
</p>
</>
)}
<button <button
type="submit" type="submit"
disabled={!canSubmit} disabled={!canSubmit}
@@ -886,40 +666,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
); );
} }
function ProLockedField({
label,
locked,
hint,
children,
}: {
label: string;
locked: boolean;
hint: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="mb-1 flex items-center gap-2 text-sm text-gray-400">
{label}
{locked && (
<span
className="rounded border border-accent/50 bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent"
title={hint}
>
Pro
</span>
)}
</label>
<div className="relative">
{children}
{locked && (
<p className="mt-1 text-xs text-gray-500">{hint}</p>
)}
</div>
</div>
);
}
function Checkbox({ function Checkbox({
label, label,
checked, checked,
-53
View File
@@ -1,53 +0,0 @@
"use client";
import { UpgradeProButton } from "./UpgradeProButton";
type Props = {
open: boolean;
onContinueWithWatermark: () => void;
onClose: () => void;
};
/**
* Shown when a free user who already used their 2 watermark-free videos
* starts another render.
*/
export function WatermarkPaywallModal({ open, onContinueWithWatermark, onClose }: Props) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="watermark-paywall-title"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-lg border border-gray-700 bg-surface-light p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 id="watermark-paywall-title" className="text-lg font-semibold text-white">
You&apos;ve used your 2 free watermark-free videos! 🎉
</h2>
<p className="mt-3 text-sm leading-relaxed text-gray-300">
Subsequent videos in the Free tier will include a small Songs2VID watermark. Upgrade to Pro
for unwatermarked videos, 4K export, and priority rendering.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onContinueWithWatermark}
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-200 hover:border-gray-400 hover:text-white"
>
Continue with Watermark
</button>
<UpgradeProButton
label="Upgrade to Pro"
className="inline-flex items-center justify-center rounded bg-accent px-4 py-2 text-sm font-medium text-black hover:opacity-90"
/>
</div>
</div>
</div>
);
}
-19
View File
@@ -23,7 +23,6 @@ import {
type WatermarkPosition, type WatermarkPosition,
type WatermarkSettings, type WatermarkSettings,
} from "@/lib/watermark"; } from "@/lib/watermark";
import { UpgradeProButton } from "./UpgradeProButton";
type Props = { type Props = {
enabled: boolean; enabled: boolean;
@@ -203,26 +202,8 @@ export function WatermarkPreview({
locked ? "opacity-80" : "" locked ? "opacity-80" : ""
}`} }`}
> >
{locked && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 rounded-lg bg-black/55 px-4 text-center backdrop-blur-[1px]">
<span className="rounded border border-accent/50 bg-accent/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-accent">
Pro
</span>
<p className="max-w-sm text-sm text-gray-200">
Custom branding, typography, logo overlay, and position controls are Pro features.
</p>
<UpgradeProButton
label="Unlock watermark studio"
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
/>
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2"> <div className="mb-4 flex flex-wrap items-center gap-2">
<h3 className="text-lg font-medium text-white">Watermark layout</h3> <h3 className="text-lg font-medium text-white">Watermark layout</h3>
<span className="rounded border border-accent/40 bg-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
Pro / B2B
</span>
</div> </div>
<div <div
+4 -4
View File
@@ -5,15 +5,15 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: s2yt POSTGRES_USER: songs2vid
POSTGRES_PASSWORD: s2yt POSTGRES_PASSWORD: songs2vid
POSTGRES_DB: s2yt POSTGRES_DB: songs2vid
ports: ports:
- "127.0.0.1:5433:5432" - "127.0.0.1:5433:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"] test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
+6 -9
View File
@@ -3,13 +3,13 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: s2yt POSTGRES_USER: songs2vid
POSTGRES_PASSWORD: s2yt POSTGRES_PASSWORD: songs2vid
POSTGRES_DB: s2yt POSTGRES_DB: songs2vid
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U s2yt -d s2yt"] test: ["CMD-SHELL", "pg_isready -U songs2vid -d songs2vid"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -28,15 +28,13 @@ services:
web: web:
build: . build: .
# Multi-arch Hub release: ./scripts/release-cloud.sh --push
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${S2VID_PORT:-3000}:3000" - "${S2VID_PORT:-3000}:3000"
env_file: env_file:
- .env - .env
environment: environment:
S2VID_EDITION: selfhosted DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
@@ -56,8 +54,7 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
S2VID_EDITION: selfhosted DATABASE_URL: postgresql://songs2vid:songs2vid@postgres:5432/songs2vid
DATABASE_URL: postgresql://s2yt:s2yt@postgres:5432/s2yt
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
volumes: volumes:
-15
View File
@@ -1,15 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export function requireAdmin(req: NextRequest): NextResponse | null {
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 });
}
return null;
}
+3 -24
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { findUserByApiKey } from "./api-keys"; import { findUserByApiKey } from "./api-keys";
import { checkApiRateLimit } from "./api-rate-limit"; import { checkApiRateLimit } from "./api-rate-limit";
import { hasProFeatures } from "./edition";
import { getSessionUser } from "./session"; import { getSessionUser } from "./session";
function extractBearerToken(req: NextRequest) { function extractBearerToken(req: NextRequest) {
@@ -10,7 +9,7 @@ function extractBearerToken(req: NextRequest) {
return auth.slice(7).trim(); return auth.slice(7).trim();
} }
export async function requirePaidApiUser(req: NextRequest) { export async function requireApiUser(req: NextRequest) {
const token = extractBearerToken(req); const token = extractBearerToken(req);
if (!token) { if (!token) {
return { return {
@@ -30,16 +29,6 @@ export async function requirePaidApiUser(req: NextRequest) {
}; };
} }
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API access requires an active Pro subscription" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) { if (!user.youtubeConnection) {
return { return {
error: NextResponse.json( error: NextResponse.json(
@@ -70,8 +59,8 @@ export async function requirePaidApiUser(req: NextRequest) {
return { error: null, user }; return { error: null, user };
} }
export async function requirePaidUserFromSessionOrApi(req: NextRequest) { export async function requireUserFromSessionOrApi(req: NextRequest) {
const apiResult = await requirePaidApiUser(req); const apiResult = await requireApiUser(req);
if (apiResult.user) return apiResult; if (apiResult.user) return apiResult;
const sessionUser = await getSessionUser(); const sessionUser = await getSessionUser();
@@ -84,16 +73,6 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
}; };
} }
if (!hasProFeatures(sessionUser.plan)) {
return {
error: NextResponse.json(
{ error: "API access requires an active Pro subscription" },
{ status: 403 },
),
user: null,
};
}
if (!sessionUser.youtubeConnection) { if (!sessionUser.youtubeConnection) {
return { return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }), error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
+3 -16
View File
@@ -1,11 +1,6 @@
import IORedis from "ioredis"; import IORedis from "ioredis";
import { prisma } from "./db"; export const DEFAULT_API_RATE_LIMIT = 100_000;
import { isSelfHostedEdition } from "./edition";
export const DEFAULT_API_RATE_LIMIT = 60;
export const API_RATE_WINDOW_SECONDS = 60; export const API_RATE_WINDOW_SECONDS = 60;
export const MAX_ADMIN_API_RATE_BONUS = 120;
const SELFHOSTED_API_RATE_LIMIT = 100_000;
type MemoryBucket = { type MemoryBucket = {
count: number; count: number;
@@ -36,13 +31,8 @@ function rateKey(userId: string) {
} }
export async function getEffectiveApiRateLimit(userId: string) { export async function getEffectiveApiRateLimit(userId: string) {
if (isSelfHostedEdition()) return SELFHOSTED_API_RATE_LIMIT; void userId;
const user = await prisma.user.findUnique({ return DEFAULT_API_RATE_LIMIT;
where: { id: userId },
select: { apiRateLimitBonus: true, plan: true },
});
if (!user || user.plan !== "PREMIUM") return DEFAULT_API_RATE_LIMIT;
return DEFAULT_API_RATE_LIMIT + Math.max(0, user.apiRateLimitBonus);
} }
function memoryStatus(userId: string, limit: number) { function memoryStatus(userId: string, limit: number) {
@@ -120,9 +110,6 @@ function checkMemoryRateLimit(userId: string, limit: number) {
} }
export async function checkApiRateLimit(userId: string) { export async function checkApiRateLimit(userId: string) {
if (isSelfHostedEdition()) {
return { ok: true as const, limit: SELFHOSTED_API_RATE_LIMIT, remaining: SELFHOSTED_API_RATE_LIMIT };
}
const limit = await getEffectiveApiRateLimit(userId); const limit = await getEffectiveApiRateLimit(userId);
const client = getRedis(); const client = getRedis();
if (!client) return checkMemoryRateLimit(userId, limit); if (!client) return checkMemoryRateLimit(userId, limit);
-4
View File
@@ -2,7 +2,6 @@ import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
import { encryptSecret } from "./crypto/secrets"; import { encryptSecret } from "./crypto/secrets";
import { prisma } from "./db"; import { prisma } from "./db";
import { getInitialQuotaResetAt } from "./quota";
import { fetchYouTubeChannel } from "./youtube/upload"; import { fetchYouTubeChannel } from "./youtube/upload";
export const authOptions: NextAuthOptions = { export const authOptions: NextAuthOptions = {
@@ -36,8 +35,6 @@ export const authOptions: NextAuthOptions = {
email: user.email, email: user.email,
name: user.name, name: user.name,
image: user.image, image: user.image,
monthlyCredits: 10,
quotaResetAt: getInitialQuotaResetAt(),
}, },
update: { update: {
name: user.name, name: user.name,
@@ -88,7 +85,6 @@ export const authOptions: NextAuthOptions = {
}); });
if (dbUser) { if (dbUser) {
session.user.id = dbUser.id; session.user.id = dbUser.id;
session.user.plan = dbUser.plan;
session.user.youtubeConnected = !!dbUser.youtubeConnection; session.user.youtubeConnected = !!dbUser.youtubeConnection;
session.user.channelTitle = dbUser.youtubeConnection?.channelTitle; session.user.channelTitle = dbUser.youtubeConnection?.channelTitle;
} }
-307
View File
@@ -1,307 +0,0 @@
import { Plan } from "@prisma/client";
import {
EXTRA_CREDITS_MAX,
FREE_TOP_UP_CREDITS,
MAX_CREDIT_CAP,
monthlyCreditsForPlan,
} from "./credits";
import { prisma } from "./db";
import { getNextMonthlyQuotaReset } from "./plans";
export class CreditInsufficientError extends Error {
readonly status = 402;
constructor(message = "Payment Required: no credits remaining.") {
super(message);
this.name = "CreditInsufficientError";
}
}
export type DeductResult = {
fromMonthly: number;
fromExtra: number;
};
export type RenewalBalances = {
monthlyCredits: number;
videosUsed: number;
extraCredits: number;
bonusQuota: number;
trimmed: number;
totalAfter: number;
};
/**
* Rollover + hard cap on renewal.
* prospective = unused (monthly remaining + extras) + newMonthly
* If prospective > MAX_CREDIT_CAP (30), trim excess (no refund).
*
* Storage: prefer filling the new monthly allocation first, leftover as extras.
*/
export function computeRenewalBalances(input: {
monthlyCredits: number;
videosUsed: number;
bonusQuota?: number;
extraCredits: number;
newMonthlyCredits: number;
maxCap?: number;
}): RenewalBalances {
const cap = input.maxCap ?? MAX_CREDIT_CAP;
const bonus = input.bonusQuota ?? 0;
const unusedMonthly = Math.max(0, input.monthlyCredits + bonus - input.videosUsed);
const currentUnused = unusedMonthly + Math.max(0, input.extraCredits);
const prospective = currentUnused + input.newMonthlyCredits;
const totalAfter = Math.min(prospective, cap);
const trimmed = Math.max(0, prospective - totalAfter);
const monthlyCredits = Math.min(input.newMonthlyCredits, totalAfter);
const extraCredits = Math.max(0, totalAfter - monthlyCredits);
return {
monthlyCredits,
videosUsed: 0,
extraCredits,
bonusQuota: 0,
trimmed,
totalAfter,
};
}
/**
* Apply monthly credit renewal with MAX_CREDIT_CAP rollover trim.
*/
export async function applyMonthlyCreditRenewal(
userId: string,
opts: {
plan?: Plan;
newMonthlyCredits?: number;
quotaResetAt?: Date;
} = {},
) {
return prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
const plan = opts.plan ?? user.plan;
const newMonthly = opts.newMonthlyCredits ?? monthlyCreditsForPlan(plan);
const balances = computeRenewalBalances({
monthlyCredits: user.monthlyCredits,
videosUsed: user.videosUsed,
bonusQuota: user.bonusQuota,
extraCredits: user.extraCredits,
newMonthlyCredits: newMonthly,
});
return tx.user.update({
where: { id: userId },
data: {
plan,
monthlyCredits: balances.monthlyCredits,
videosUsed: balances.videosUsed,
extraCredits: balances.extraCredits,
bonusQuota: balances.bonusQuota,
quotaResetAt: opts.quotaResetAt ?? getNextMonthlyQuotaReset(),
},
});
});
}
/**
* Deduct video creation credits for `count` items.
* Order: monthly allocation first, then extraCredits.
*/
export async function deductUserCredit(
userId: string,
count = 1,
): Promise<DeductResult> {
if (count <= 0) return { fromMonthly: 0, fromExtra: 0 };
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{
id: string;
plan: Plan;
videosUsed: number;
monthlyCredits: number;
bonusQuota: number;
videoCredits: number;
}>
>`SELECT id, plan, "videosUsed", "monthlyCredits", "bonusQuota", "videoCredits"
FROM "User" WHERE id = ${userId} FOR UPDATE`;
const user = rows[0];
if (!user) throw new Error("User not found");
const monthlyLimit = user.monthlyCredits + user.bonusQuota;
const monthlyRemaining = Math.max(0, monthlyLimit - user.videosUsed);
const extra = user.videoCredits;
const total = monthlyRemaining + extra;
if (total < count) {
throw new CreditInsufficientError(
`Not enough credits. Available: ${total} (monthly remaining ${monthlyRemaining} + extras ${extra}).`,
);
}
const fromMonthly = Math.min(count, monthlyRemaining);
const fromExtra = count - fromMonthly;
await tx.user.update({
where: { id: userId },
data: {
...(fromMonthly > 0 ? { videosUsed: { increment: fromMonthly } } : {}),
...(fromExtra > 0 ? { extraCredits: { decrement: fromExtra } } : {}),
},
});
return { fromMonthly, fromExtra };
});
}
/** Refund reserved credits (job create failure / worker failure). */
export async function refundUserCredit(
userId: string,
fromMonthly: number,
fromExtra: number,
) {
if (fromMonthly > 0) {
await prisma.$executeRaw`
UPDATE "User"
SET "videosUsed" = GREATEST(0, "videosUsed" - ${fromMonthly})
WHERE id = ${userId}
`;
}
if (fromExtra > 0) {
await prisma.$executeRaw`
UPDATE "User"
SET "videoCredits" = LEAST(${EXTRA_CREDITS_MAX}, "videoCredits" + ${fromExtra})
WHERE id = ${userId}
`;
}
}
/**
* Admin / support: reset or adjust a user's billing cycle and extras.
*/
export async function adminResetUserCredits(
userId: string,
options: {
plan?: Plan;
monthlyCredits?: number;
creditsUsed?: number;
extraCredits?: number;
clearFreeTopUp?: boolean;
} = {},
) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const plan = options.plan ?? user.plan;
const monthlyCredits =
options.monthlyCredits ?? monthlyCreditsForPlan(plan);
const extras =
options.extraCredits !== undefined
? Math.max(0, Math.min(EXTRA_CREDITS_MAX, options.extraCredits))
: user.extraCredits;
const used = options.creditsUsed ?? 0;
const remainingMonthly = Math.max(0, monthlyCredits - used);
const total = remainingMonthly + extras;
const cappedExtras =
total > MAX_CREDIT_CAP
? Math.max(0, MAX_CREDIT_CAP - remainingMonthly)
: extras;
return prisma.user.update({
where: { id: userId },
data: {
plan,
subscriptionStatus: plan === "PREMIUM" ? "pro" : "free",
monthlyCredits,
videosUsed: used,
extraCredits: cappedExtras,
...(options.clearFreeTopUp ? { freeTopUpPurchased: false } : {}),
quotaResetAt: getNextMonthlyQuotaReset(),
...(plan === "FREE"
? {
stripeSubscriptionId: null,
subscribedAt: null,
cardLast4: null,
bonusQuota: 0,
}
: {}),
},
});
}
export async function activateProPlan(
userId: string,
opts: {
stripeCustomerId?: string | null;
stripeSubscriptionId?: string | null;
cardLast4?: string | null;
} = {},
) {
await applyMonthlyCreditRenewal(userId, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
});
return prisma.user.update({
where: { id: userId },
data: {
plan: "PREMIUM",
subscriptionStatus: "pro",
subscribedAt: new Date(),
...(opts.stripeCustomerId !== undefined
? { stripeCustomerId: opts.stripeCustomerId }
: {}),
...(opts.stripeSubscriptionId !== undefined
? { stripeSubscriptionId: opts.stripeSubscriptionId }
: {}),
...(opts.cardLast4 !== undefined ? { cardLast4: opts.cardLast4 } : {}),
},
});
}
export async function downgradeToFreePlan(userId: string) {
await applyMonthlyCreditRenewal(userId, {
plan: "FREE",
newMonthlyCredits: monthlyCreditsForPlan("FREE"),
});
return prisma.user.update({
where: { id: userId },
data: {
plan: "FREE",
subscriptionStatus: "free",
stripeSubscriptionId: null,
subscribedAt: null,
cardLast4: null,
apiKeyHash: null,
apiKeyPrefix: null,
apiRateLimitBonus: 0,
},
});
}
export async function grantExtraCredits(userId: string, credits: number) {
if (credits <= 0) return;
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id: userId } });
const monthlyRemaining = Math.max(
0,
user.monthlyCredits + user.bonusQuota - user.videosUsed,
);
const room = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - user.extraCredits);
const grant = Math.min(credits, room);
if (grant <= 0) return;
await tx.user.update({
where: { id: userId },
data: { extraCredits: { increment: grant } },
});
});
}
export async function markFreeTopUpPurchased(userId: string) {
await prisma.user.update({
where: { id: userId },
data: { freeTopUpPurchased: true },
});
await grantExtraCredits(userId, FREE_TOP_UP_CREDITS);
}
-7
View File
@@ -1,10 +1,3 @@
export const FREE_PLAN = {
monthlyQuota: 10,
maxFileSizeBytes: 30 * 1024 * 1024,
maxBatchSize: 3,
watermarkOptional: true,
} as const;
export const RESOLUTIONS = [ export const RESOLUTIONS = [
{ value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 }, { value: "1920x1080", label: "1920x1080 (16:9)", width: 1920, height: 1080 },
{ value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 }, { value: "1280x720", label: "1280x720 (16:9)", width: 1280, height: 720 },
-113
View File
@@ -1,113 +0,0 @@
/**
* Billing / credit constants for Songs2VID hybrid pricing.
* Plan enum in DB: FREE | PREMIUM (Pro = 5/mo "starter_5eur").
*/
export const CREDIT_CURRENCY = "eur";
/** Free monthly allocation */
export const FREE_MONTHLY_CREDITS = 10;
/** Pro monthly allocation */
export const PRO_MONTHLY_CREDITS = 50;
/** Free-tier top-up: buyer picks 115 credits */
export const FREE_TOP_UP_MIN = 1;
export const FREE_TOP_UP_MAX = 15;
/** €0.25 per extra credit */
export const CREDIT_PRICE_CENTS = 25;
/** @deprecated use FREE_TOP_UP_MAX */
export const FREE_TOP_UP_CREDITS = FREE_TOP_UP_MAX;
/** @deprecated use creditPurchaseTotalCents(FREE_TOP_UP_MAX) */
export const FREE_TOP_UP_PRICE_CENTS = FREE_TOP_UP_MAX * CREDIT_PRICE_CENTS;
/** Pro subscription €5 / month */
export const PRO_PRICE_CENTS = 500;
/** Soft cap on never-expiring extra credits (Pro / legacy storage bound) */
export const EXTRA_CREDITS_MAX = 1000;
/**
* Hard cap on total accumulated credits (monthly remaining + extras).
* On renewal, unused + new monthly is trimmed to this threshold.
*/
export const MAX_CREDIT_CAP = 30;
/**
* Free plan: extras balance cannot exceed 15.
* After monthly (10) + extras (15) are used, upgrade to Pro is required.
*/
export const FREE_EXTRA_CREDITS_MAX = FREE_TOP_UP_MAX;
/** Stripe Tax / Managed Payments SaaS personal use */
export const STRIPE_PRODUCT_TAX_CODE = "txcd_10103000";
export const CREDIT_PURCHASE_MIN = FREE_TOP_UP_MIN;
export const CREDIT_PURCHASE_MAX = FREE_TOP_UP_MAX;
export const CREDIT_BALANCE_MAX = EXTRA_CREDITS_MAX;
/** Shown in quota UI errors so UpgradeProLink can attach a CTA */
export const PRO_UPGRADE_REQUIRED_SUFFIX = " Upload up to 50 videos with Pro!";
export function monthlyCreditsForPlan(plan: "FREE" | "PREMIUM"): number {
return plan === "PREMIUM" ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
}
export function freeExtraCreditsCap(): number {
return FREE_EXTRA_CREDITS_MAX;
}
export function formatEuroFromCents(cents: number): string {
const amount = cents / 100;
const formatted = amount % 1 === 0 ? String(amount) : amount.toFixed(2);
return `${formatted}`;
}
export function creditPurchaseTotalCents(credits: number): number {
return credits * CREDIT_PRICE_CENTS;
}
export function formatCreditPrice(credits = 1): string {
return formatEuroFromCents(creditPurchaseTotalCents(credits));
}
export function validateFreeTopUpAmount(
credits: number,
currentExtraBalance: number,
monthlyRemaining = 0,
): { ok: true; credits: number; amountCents: number } | { ok: false; error: string } {
if (!Number.isInteger(credits)) {
return { ok: false, error: "Credit amount must be a whole number." };
}
if (credits < FREE_TOP_UP_MIN || credits > FREE_TOP_UP_MAX) {
return {
ok: false,
error: `You can buy between ${FREE_TOP_UP_MIN} and ${FREE_TOP_UP_MAX} credits per top-up.`,
};
}
if (currentExtraBalance >= FREE_EXTRA_CREDITS_MAX) {
return {
ok: false,
error: `Free plan extras are capped at ${FREE_EXTRA_CREDITS_MAX}. Upgrade to Pro for 50 videos every month.${PRO_UPGRADE_REQUIRED_SUFFIX}`,
};
}
const freeExtraRoom = FREE_EXTRA_CREDITS_MAX - currentExtraBalance;
const totalRoom = Math.max(0, MAX_CREDIT_CAP - monthlyRemaining - currentExtraBalance);
const room = Math.min(freeExtraRoom, totalRoom);
if (room <= 0) {
return {
ok: false,
error: `Your total credit balance cannot exceed ${MAX_CREDIT_CAP}. Upgrade to Pro or use existing credits first.${PRO_UPGRADE_REQUIRED_SUFFIX}`,
};
}
if (credits > room) {
return {
ok: false,
error: `You can buy up to ${room} more credits under the ${MAX_CREDIT_CAP}-credit account cap.`,
};
}
return { ok: true, credits, amountCents: creditPurchaseTotalCents(credits) };
}
+3 -5
View File
@@ -1,9 +1,7 @@
import { Plan } from "@prisma/client";
export function isSelfHostedEdition(): boolean { export function isSelfHostedEdition(): boolean {
return process.env.S2VID_EDITION === "selfhosted"; return true;
} }
export function hasProFeatures(plan: Plan): boolean { export function hasProFeatures(): boolean {
return isSelfHostedEdition() || plan === "PREMIUM"; return true;
} }
+7 -58
View File
@@ -1,62 +1,11 @@
import { Plan } from "@prisma/client"; import type { LayoutSettings } from "./layout";
import { getPlanLimits } from "./plans"; import type { WatermarkSettings } from "./watermark";
import { hasProFeatures } from "./edition";
import {
requiresArtTrackLayoutEntitlement,
type LayoutSettings,
} from "./layout";
import {
PREMIUM_REQUIRED_CODE,
requiresCustomWatermarkEntitlement,
type WatermarkSettings,
} from "./watermark";
export class PremiumRequiredError extends Error { export function assertCustomWatermarkAllowed(_settings: WatermarkSettings) {}
readonly code = PREMIUM_REQUIRED_CODE;
readonly status = 403;
constructor(message: string) {
super(message);
this.name = "PremiumRequiredError";
}
}
export function assertCustomWatermarkAllowed(plan: Plan, settings: WatermarkSettings) { export function assertArtTrackLayoutAllowed(_settings: LayoutSettings) {}
if (!requiresCustomWatermarkEntitlement(settings)) return;
if (getPlanLimits(plan).customWatermark || hasProFeatures(plan)) return;
throw new PremiumRequiredError(
"Custom watermark, typography, logo overlay, and position controls require Pro.",
);
}
export function assertArtTrackLayoutAllowed(plan: Plan, settings: LayoutSettings) {
if (!requiresArtTrackLayoutEntitlement(settings)) return;
if (getPlanLimits(plan).artTrackLayouts || hasProFeatures(plan)) return;
throw new PremiumRequiredError(
"Blurred backgrounds and art-track layout templates require Pro.",
);
}
export function assertPerItemImagesAllowed( export function assertPerItemImagesAllowed(
plan: Plan, _sharedImagePath: string,
sharedImagePath: string, _itemImagePaths: Array<string | null | undefined>,
itemImagePaths: Array<string | null | undefined>, ) {}
) {
const unique = new Set(
itemImagePaths
.map((p) => (p && p.trim() ? p.trim() : sharedImagePath))
.filter(Boolean),
);
// More than one distinct cover in the batch → Pro
if (unique.size <= 1) return;
if (getPlanLimits(plan).perItemImages || hasProFeatures(plan)) return;
throw new PremiumRequiredError(
"Matching a unique image to each audio file requires Pro. Free plan uses one shared cover image.",
);
}
export function premiumRequiredResponse(message: string) {
return {
error: message,
code: PREMIUM_REQUIRED_CODE,
};
}
-67
View File
@@ -1,67 +0,0 @@
export type FooterBadgeInput = {
name?: string;
embedHtml?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
isActive?: boolean;
sortOrder?: number;
};
export type ParsedBadgeEmbed = {
imageUrl: string | null;
linkUrl: string | null;
};
/** Extract first <a href> + nested/nearby <img src> from pasted badge HTML. */
export function parseBadgeEmbedHtml(html: string): ParsedBadgeEmbed {
const trimmed = html.trim();
if (!trimmed) return { imageUrl: null, linkUrl: null };
const hrefMatch =
trimmed.match(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/i) ??
trimmed.match(/href\s*=\s*["']([^"']+)["']/i);
const srcMatch =
trimmed.match(/<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/i) ??
trimmed.match(/src\s*=\s*["']([^"']+)["']/i);
return {
linkUrl: hrefMatch?.[1]?.trim() || null,
imageUrl: srcMatch?.[1]?.trim() || null,
};
}
export function normalizeBadgeFields(input: FooterBadgeInput) {
const name = (input.name ?? "").trim();
const embedHtml = input.embedHtml?.trim() || null;
let imageUrl = input.imageUrl?.trim() || null;
let linkUrl = input.linkUrl?.trim() || null;
if (embedHtml) {
const parsed = parseBadgeEmbedHtml(embedHtml);
if (!imageUrl && parsed.imageUrl) imageUrl = parsed.imageUrl;
if (!linkUrl && parsed.linkUrl) linkUrl = parsed.linkUrl;
}
return {
name,
embedHtml,
imageUrl,
linkUrl,
isActive: input.isActive ?? true,
sortOrder:
typeof input.sortOrder === "number" && Number.isFinite(input.sortOrder)
? Math.trunc(input.sortOrder)
: 0,
};
}
export function validateBadgeFields(fields: ReturnType<typeof normalizeBadgeFields>): string | null {
if (!fields.name) return "Name is required";
if (!fields.embedHtml && !fields.imageUrl) {
return "Provide embed HTML or an image URL";
}
if (fields.imageUrl && !fields.linkUrl && !fields.embedHtml) {
return "Link URL is required when using image URL only";
}
return null;
}
+23 -63
View File
@@ -1,15 +1,13 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { Plan, Privacy } from "@prisma/client"; import { Privacy } from "@prisma/client";
import { readAudioTags } from "../audio-tags"; import { readAudioTags } from "../audio-tags";
import { filenameWithoutExtension, isAllowedResolution } from "../constants"; import { filenameWithoutExtension, isAllowedResolution } from "../constants";
import { prisma } from "../db"; import { prisma } from "../db";
import { hasProFeatures } from "../edition";
import { import {
assertArtTrackLayoutAllowed, assertArtTrackLayoutAllowed,
assertCustomWatermarkAllowed, assertCustomWatermarkAllowed,
assertPerItemImagesAllowed, assertPerItemImagesAllowed,
PremiumRequiredError,
} from "../entitlements"; } from "../entitlements";
import { FONT_UPLOAD_MAX_BYTES } from "../fonts"; import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
import { assertFontFile } from "../fonts-server"; import { assertFontFile } from "../fonts-server";
@@ -28,7 +26,7 @@ import {
isAudioExtensionAllowed, isAudioExtensionAllowed,
isResolutionAllowedForPlan, isResolutionAllowedForPlan,
} from "../plans"; } from "../plans";
import { releaseReservationSplit, reserveQuota } from "../quota"; import { reserveQuota } from "../quota";
import { getJobDir } from "../storage"; import { getJobDir } from "../storage";
import { import {
assertPathInUserUploads, assertPathInUserUploads,
@@ -88,9 +86,8 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
export function validateItemMetadata( export function validateItemMetadata(
metadata: CreateJobPayload["items"][0]["metadata"], metadata: CreateJobPayload["items"][0]["metadata"],
plan: Plan,
) { ) {
const titleErr = validateYouTubeTitle(metadata, plan); const titleErr = validateYouTubeTitle(metadata);
if (titleErr) return titleErr; if (titleErr) return titleErr;
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution"; if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) { if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
@@ -116,7 +113,7 @@ export function validateItemMetadata(
return null; return null;
} }
export async function validateJobPayload(user: { id: string; plan: Plan }, body: CreateJobPayload) { export async function validateJobPayload(user: { id: string }, body: CreateJobPayload) {
if (!body.imagePath || !body.items?.length) { if (!body.imagePath || !body.items?.length) {
return "Image and at least one audio file required"; return "Image and at least one audio file required";
} }
@@ -124,13 +121,10 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
const itemImagePaths: Array<string | null | undefined> = []; const itemImagePaths: Array<string | null | undefined> = [];
for (const item of body.items) { for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata, user.plan); const metaError = validateItemMetadata(item.metadata);
if (metaError) return metaError; if (metaError) return metaError;
if (!isResolutionAllowedForPlan(item.metadata.resolution, user.plan)) { if (!isResolutionAllowedForPlan(item.metadata.resolution)) {
return `Resolution ${item.metadata.resolution} is not available on your plan`; return `Resolution ${item.metadata.resolution} is not available`;
}
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
return "Adding videos to a YouTube playlist requires the Pro plan";
} }
const wm = normalizeWatermarkSettings( const wm = normalizeWatermarkSettings(
@@ -138,10 +132,9 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
item.metadata.includeWatermark, item.metadata.includeWatermark,
); );
try { try {
assertCustomWatermarkAllowed(user.plan, wm); assertCustomWatermarkAllowed(wm);
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata)); assertArtTrackLayoutAllowed(resolveLayoutFromMetadata(item.metadata));
} catch (err) { } catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) { if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
return INVALID_LAYOUT_TEMPLATE_MESSAGE; return INVALID_LAYOUT_TEMPLATE_MESSAGE;
} }
@@ -152,13 +145,12 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
} }
try { try {
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths); assertPerItemImagesAllowed(body.imagePath, itemImagePaths);
} catch (err) { } catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
throw err; throw err;
} }
const limits = getPlanLimits(user.plan); const limits = getPlanLimits();
try { try {
const imagePath = assertPathInUserUploads(user.id, body.imagePath); const imagePath = assertPathInUserUploads(user.id, body.imagePath);
@@ -169,8 +161,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
} }
for (const item of body.items) { for (const item of body.items) {
if (!isAudioExtensionAllowed(item.audioFilename, user.plan)) { if (!isAudioExtensionAllowed(item.audioFilename)) {
return `Audio file ${item.audioFilename} is not supported on your plan`; return `Audio file ${item.audioFilename} is not supported`;
} }
const audioPath = assertPathInUserUploads(user.id, item.audioPath); const audioPath = assertPathInUserUploads(user.id, item.audioPath);
await fs.access(audioPath); await fs.access(audioPath);
@@ -218,7 +210,6 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
} }
} }
} catch (err) { } catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === "Invalid upload path") { if (err instanceof Error && err.message === "Invalid upload path") {
return "Invalid upload path"; return "Invalid upload path";
} }
@@ -239,7 +230,7 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
} }
export async function createVideoJob( export async function createVideoJob(
user: { id: string; plan: Plan }, user: { id: string },
body: CreateJobPayload, body: CreateJobPayload,
) { ) {
const validationError = await validateJobPayload(user, body); const validationError = await validateJobPayload(user, body);
@@ -247,21 +238,9 @@ export async function createVideoJob(
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) { if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
throw new Error(validationError); throw new Error(validationError);
} }
const isPremium = throw new Error(validationError);
validationError.includes("requires Pro") ||
validationError.includes("Pro plan");
const err = isPremium
? new PremiumRequiredError(validationError)
: new Error(validationError);
throw err;
} }
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { createdVideoCount: true },
});
const createdVideoCount = dbUser?.createdVideoCount ?? 0;
const imagePath = assertPathInUserUploads(user.id, body.imagePath); const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({ const items = body.items.map((item) => ({
...item, ...item,
@@ -278,7 +257,7 @@ export async function createVideoJob(
: null, : null,
})); }));
const reservation = await reserveQuota(user.id, items.length); await reserveQuota(user.id, items.length);
try { try {
const job = await prisma.job.create({ const job = await prisma.job.create({
@@ -296,21 +275,13 @@ export async function createVideoJob(
} }
: null, : null,
item.metadata.includeWatermark, item.metadata.includeWatermark,
{
plan: user.plan,
createdVideoCount,
itemIndex: index,
},
); );
const layout = resolveLayoutFromMetadata(item.metadata); const layout = resolveLayoutFromMetadata(item.metadata);
const pro = hasProFeatures(user.plan); const youtubeTitle = resolveYouTubeTitle(item.metadata);
const youtubeTitle = resolveYouTubeTitle(item.metadata, user.plan); const burnedTitle = resolveBurnedSongTitle(
const burnedTitle = pro
? resolveBurnedSongTitle(
item.metadata, item.metadata,
filenameWithoutExtension(item.audioFilename), filenameWithoutExtension(item.audioFilename),
) );
: null;
return { return {
audioPath: item.audioPath, audioPath: item.audioPath,
audioFilename: item.audioFilename, audioFilename: item.audioFilename,
@@ -336,9 +307,7 @@ export async function createVideoJob(
watermarkPosition: wm.position, watermarkPosition: wm.position,
watermarkOffsetX: wm.offsetX, watermarkOffsetX: wm.offsetX,
watermarkOffsetY: wm.offsetY, watermarkOffsetY: wm.offsetY,
artist: pro artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
? item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null
: null,
layoutTemplate: layout.template, layoutTemplate: layout.template,
blurAmount: layout.blurAmount, blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity, blurOpacity: layout.blurOpacity,
@@ -347,7 +316,6 @@ export async function createVideoJob(
textOffsetX: layout.textOffsetX, textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY, textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null, playlistId: item.metadata.playlistId?.trim() || null,
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
}; };
}), }),
}, },
@@ -430,7 +398,6 @@ export async function createVideoJob(
return job; return job;
} catch (err) { } catch (err) {
await releaseReservationSplit(user.id, reservation).catch(() => {});
throw err; throw err;
} }
} }
@@ -441,15 +408,11 @@ export async function saveUploadedFile(
userId: string, userId: string,
file: File, file: File,
type: UploadFileType, type: UploadFileType,
plan: Plan,
options?: { sessionKey?: string }, options?: { sessionKey?: string },
) { ) {
const limits = getPlanLimits(plan); const limits = getPlanLimits();
if (type === "font") { if (type === "font") {
if (!limits.customWatermark && !hasProFeatures(plan)) {
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
}
if (file.size > FONT_UPLOAD_MAX_BYTES) { if (file.size > FONT_UPLOAD_MAX_BYTES) {
throw new Error("Font file must be 10 MB or smaller"); throw new Error("Font file must be 10 MB or smaller");
} }
@@ -461,9 +424,6 @@ export async function saveUploadedFile(
} }
if (type === "logo") { if (type === "logo") {
if (!limits.customWatermark && !hasProFeatures(plan)) {
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
}
const nameOk = /\.png$/i.test(file.name); const nameOk = /\.png$/i.test(file.name);
const typeOk = file.type === "image/png" || file.type === ""; const typeOk = file.type === "image/png" || file.type === "";
if (!nameOk && !typeOk) { if (!nameOk && !typeOk) {
@@ -498,9 +458,9 @@ export async function saveUploadedFile(
) { ) {
throw new Error("Invalid audio file type"); throw new Error("Invalid audio file type");
} }
if (!isAudioExtensionAllowed(file.name, plan)) { if (!isAudioExtensionAllowed(file.name)) {
const allowed = limits.allowedAudioExtensions.join(", "); const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`); throw new Error(`Supported audio formats: ${allowed}`);
} }
} }
+1 -7
View File
@@ -1,5 +1,3 @@
import type { Plan } from "@prisma/client";
import { hasProFeatures } from "../edition";
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types"; import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "../types";
import { createYouTubePlaylist } from "../youtube/upload"; import { createYouTubePlaylist } from "../youtube/upload";
@@ -22,7 +20,7 @@ export function parseCreatePlaylistInput(raw: unknown): CreatePlaylistRequest |
} }
export async function applyCreatePlaylistToItems( export async function applyCreatePlaylistToItems(
user: { id: string; plan: Plan }, user: { id: string },
items: CreateJobPayload["items"], items: CreateJobPayload["items"],
createPlaylist: CreatePlaylistRequest | null | undefined, createPlaylist: CreatePlaylistRequest | null | undefined,
) { ) {
@@ -30,10 +28,6 @@ export async function applyCreatePlaylistToItems(
return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null }; return { items, playlist: null as Awaited<ReturnType<typeof createYouTubePlaylist>> | null };
} }
if (!hasProFeatures(user.plan)) {
throw new Error("Creating a YouTube playlist requires the Pro plan");
}
const playlist = await createYouTubePlaylist(user.id, createPlaylist); const playlist = await createYouTubePlaylist(user.id, createPlaylist);
const nextItems = items.map((item) => ({ const nextItems = items.map((item) => ({
...item, ...item,
+1 -3
View File
@@ -1,4 +1,4 @@
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans"; import { SUPPORT_EMAIL } from "@/lib/plans";
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding"; import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
export const LEGAL_LAST_UPDATED = "July 30, 2026"; export const LEGAL_LAST_UPDATED = "July 30, 2026";
@@ -9,7 +9,5 @@ export const LEGAL_OPERATOR = {
address: "Universitas u. 2/A", address: "Universitas u. 2/A",
city: "7622 Pécs, Hungary", city: "7622 Pécs, Hungary",
email: SUPPORT_EMAIL, email: SUPPORT_EMAIL,
salesEmail: SALES_EMAIL,
quotaRequestEmail: QUOTA_REQUEST_EMAIL,
website: `https://www.${BRAND_DOMAIN}`, website: `https://www.${BRAND_DOMAIN}`,
} as const; } as const;
+10 -53
View File
@@ -1,6 +1,4 @@
import { Plan } from "@prisma/client";
import { getResolution, RESOLUTIONS } from "./constants"; import { getResolution, RESOLUTIONS } from "./constants";
import { isSelfHostedEdition } from "./edition";
export type PlanLimits = { export type PlanLimits = {
monthlyQuota: number; monthlyQuota: number;
@@ -18,35 +16,8 @@ export type PlanLimits = {
id3TagSupport: boolean; id3TagSupport: boolean;
}; };
export const PLAN_LIMITS: Record<Plan, PlanLimits> = { export const APP_LIMITS: PlanLimits = {
FREE: { monthlyQuota: Number.MAX_SAFE_INTEGER,
monthlyQuota: 10,
maxBatchSize: 3,
maxResolutionHeight: 720,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkOptional: true,
customWatermark: false,
perItemImages: false,
artTrackLayouts: false,
allowedAudioExtensions: [".mp3"],
id3TagSupport: true,
},
PREMIUM: {
monthlyQuota: 50,
maxBatchSize: 5,
maxResolutionHeight: 1080,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkOptional: true,
customWatermark: true,
perItemImages: true,
artTrackLayouts: true,
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
id3TagSupport: true,
},
};
export const SELFHOSTED_LIMITS: PlanLimits = {
monthlyQuota: 1_000_000,
maxBatchSize: 100, maxBatchSize: 100,
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)), maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
maxFileSizeBytes: 500 * 1024 * 1024, maxFileSizeBytes: 500 * 1024 * 1024,
@@ -58,11 +29,7 @@ export const SELFHOSTED_LIMITS: PlanLimits = {
id3TagSupport: true, id3TagSupport: true,
}; };
export const SALES_EMAIL = "songs2vid@atakanozban.com";
export const SUPPORT_EMAIL = "songs2vid@atakanozban.com"; export const SUPPORT_EMAIL = "songs2vid@atakanozban.com";
/** Pro quota reset / extension requests */
export const QUOTA_REQUEST_EMAIL = "songs2vid@atakanozban.com";
export const UPGRADE_URL = "/#pricing";
export const GITEA_ISSUES_URL = export const GITEA_ISSUES_URL =
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid/issues"; process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid/issues";
export const GITEA_URL = export const GITEA_URL =
@@ -70,42 +37,32 @@ export const GITEA_URL =
export const DOCKER_HUB_URL = export const DOCKER_HUB_URL =
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid"; process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid";
/** Docusaurus docs base URL. Local: http://localhost:3001 (npm run docs:dev). */
export function resolveDocsUrl(): string { export function resolveDocsUrl(): string {
if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) { if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) {
return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, ""); return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, "");
} }
const auth = process.env.NEXTAUTH_URL ?? "";
if (/localhost|127\.0\.0\.1/i.test(auth)) {
return "http://localhost:3001";
}
return "https://docs.songs2vid.com"; return "https://docs.songs2vid.com";
} }
export const DOCS_URL = resolveDocsUrl(); export const DOCS_URL = resolveDocsUrl();
export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`; export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`;
export function getPlanLimits(plan: Plan): PlanLimits { export function getPlanLimits(): PlanLimits {
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS; return APP_LIMITS;
return PLAN_LIMITS[plan];
} }
export function getResolutionsForPlan(plan: Plan) { export function getResolutionsForPlan() {
const maxHeight = getPlanLimits(plan).maxResolutionHeight; const maxHeight = APP_LIMITS.maxResolutionHeight;
return RESOLUTIONS.filter((r) => r.height <= maxHeight); return RESOLUTIONS.filter((r) => r.height <= maxHeight);
} }
export function isResolutionAllowedForPlan(resolution: string, plan: Plan): boolean { export function isResolutionAllowedForPlan(resolution: string): boolean {
const res = getResolution(resolution); const res = getResolution(resolution);
if (!res) return false; if (!res) return false;
return res.height <= getPlanLimits(plan).maxResolutionHeight; return res.height <= APP_LIMITS.maxResolutionHeight;
} }
export function isAudioExtensionAllowed(filename: string, plan: Plan): boolean { export function isAudioExtensionAllowed(filename: string): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase(); const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
return getPlanLimits(plan).allowedAudioExtensions.includes(ext); return APP_LIMITS.allowedAudioExtensions.includes(ext);
}
export function getNextMonthlyQuotaReset(from: Date = new Date()): Date {
return new Date(from.getFullYear(), from.getMonth() + 1, 1, 0, 0, 0, 0);
} }
-186
View File
@@ -1,186 +0,0 @@
import { QuotaExtensionRequestStatus } from "@prisma/client";
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
import { prisma } from "./db";
import { getPlanLimits } from "./plans";
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
/** Max bonus videos an admin may grant per approval. */
export const MAX_ADMIN_BONUS_QUOTA = 50;
export const EXTENSION_KIND = {
VIDEO_QUOTA: "VIDEO_QUOTA",
API_RATE_LIMIT: "API_RATE_LIMIT",
} as const;
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
function getCalendarYearBounds(year = new Date().getFullYear()) {
return {
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1),
};
}
export async function getQuotaExtensionUsage(
userId: string,
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
) {
const { start, end } = getCalendarYearBounds();
const requests = await prisma.quotaExtensionRequest.findMany({
where: {
userId,
kind,
requestedAt: { gte: start, lt: end },
},
orderBy: { requestedAt: "desc" },
});
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
return {
used,
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
kind,
requests: requests.map((r) => ({
id: r.id,
kind: r.kind,
status: r.status,
message: r.message,
requestedAt: r.requestedAt.toISOString(),
processedAt: r.processedAt?.toISOString() ?? null,
adminNote: r.adminNote,
})),
};
}
export async function createQuotaExtensionRequest(
userId: string,
message = "",
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (user.plan !== "PREMIUM") {
throw new Error("Only Pro subscribers can request quota resets or extensions.");
}
const usage = await getQuotaExtensionUsage(userId, kind);
if (usage.remaining <= 0) {
throw new Error(
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
} extension requests for this year.`,
);
}
const pending = await prisma.quotaExtensionRequest.findFirst({
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
});
if (pending) {
throw new Error("You already have a pending request. Please wait for it to be processed.");
}
const request = await prisma.quotaExtensionRequest.create({
data: {
userId,
kind,
message: message.trim().slice(0, 1000),
status: QuotaExtensionRequestStatus.PENDING,
},
});
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
return {
requestId: request.id,
...updatedUsage,
};
}
/** Call when approving a request in the database (admin / support). */
export async function approveQuotaExtensionRequest(
requestId: string,
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
) {
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
where: { id: requestId },
include: { user: true },
});
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
throw new Error("Request is not pending.");
}
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
const bonus = Math.min(
MAX_ADMIN_API_RATE_BONUS,
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
);
await prisma.$transaction([
prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.APPROVED,
processedAt: new Date(),
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
},
}),
prisma.user.update({
where: { id: request.userId },
data: {
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
},
}),
]);
return;
}
const bonus = Math.min(
MAX_ADMIN_BONUS_QUOTA,
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
);
await prisma.$transaction([
prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.APPROVED,
processedAt: new Date(),
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
},
}),
prisma.user.update({
where: { id: request.userId },
data: {
videosUsed: 0,
bonusQuota: request.user.bonusQuota + bonus,
},
}),
]);
}
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
where: { id: requestId },
});
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
throw new Error("Request is not pending.");
}
await prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.REJECTED,
processedAt: new Date(),
adminNote: adminNote?.trim().slice(0, 500) ?? null,
},
});
}
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
return getPlanLimits(plan).monthlyQuota + bonusQuota;
}
+16 -259
View File
@@ -1,282 +1,39 @@
import { Plan } from "@prisma/client";
import {
CreditInsufficientError,
applyMonthlyCreditRenewal,
deductUserCredit,
refundUserCredit,
} from "./billing";
import { FREE_EXTRA_CREDITS_MAX, MAX_CREDIT_CAP, PRO_UPGRADE_REQUIRED_SUFFIX, monthlyCreditsForPlan } from "./credits";
import { prisma } from "./db"; import { prisma } from "./db";
import { hasProFeatures, isSelfHostedEdition } from "./edition"; import { APP_LIMITS } from "./plans";
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
import { watermarkFreeRendersRemaining } from "./watermark-policy";
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
const ms = Math.max(0, target.getTime() - Date.now());
const totalSec = Math.ceil(ms / 1000);
const hours = Math.floor(totalSec / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
return `${hours}h ${minutes}m ${seconds}s`;
}
export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string {
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
return target.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}
function getQuotaExceededMessage(
plan: Plan,
resetsAt: Date,
extraCredits: number,
): string {
const resetLabel = formatQuotaResetDisplay(plan, resetsAt);
if (plan === "PREMIUM") {
return extraCredits > 0
? `Monthly Pro quota exhausted. You still have ${extraCredits} extra credits.`
: `Quota exceeded. Your Pro monthly credits reset on ${resetLabel}.`;
}
if (extraCredits > 0) {
return `Monthly free quota exhausted. You still have ${extraCredits} extra credits.`;
}
return (
`You've used your free monthly credits (10) and any extra top-ups (max ${FREE_EXTRA_CREDITS_MAX}). ` +
`Upgrade to the Pro monthly plan to continue uploading.` +
PRO_UPGRADE_REQUIRED_SUFFIX
);
}
/** Reset cycle when due: rollover unused into extras, add new monthly, trim to MAX_CREDIT_CAP. */
export async function ensureQuotaReset(userId: string) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const now = new Date();
const expectedMonthly = monthlyCreditsForPlan(user.plan);
if (now >= user.quotaResetAt) {
return applyMonthlyCreditRenewal(userId, {
plan: user.plan,
newMonthlyCredits: expectedMonthly,
quotaResetAt: getNextMonthlyQuotaReset(now),
});
}
// Keep monthlyCredits in sync if plan was changed mid-cycle without reset
if (user.monthlyCredits !== expectedMonthly && user.videosUsed === 0 && user.extraCredits === 0) {
return prisma.user.update({
where: { id: userId },
data: { monthlyCredits: expectedMonthly },
});
}
return user;
}
export async function getQuotaInfo(userId: string) { export async function getQuotaInfo(userId: string) {
const user = await ensureQuotaReset(userId); const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const limits = getPlanLimits(user.plan);
const limit = isSelfHostedEdition()
? limits.monthlyQuota
: user.monthlyCredits + (user.plan === "PREMIUM" ? user.bonusQuota : 0);
const remaining = Math.max(0, limit - user.videosUsed);
const extraCredits = isSelfHostedEdition() ? 0 : user.extraCredits;
return { return {
used: user.videosUsed, used: user.createdVideoCount,
creditsUsed: user.videosUsed,
limit,
monthlyCredits: user.monthlyCredits,
baseLimit: limits.monthlyQuota,
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
remaining,
/** @deprecated use extraCredits */
videoCredits: extraCredits,
extraCredits,
creditBalanceMax: MAX_CREDIT_CAP,
requiresProUpgrade:
!isSelfHostedEdition() &&
user.plan === "FREE" &&
remaining === 0 &&
extraCredits === 0,
freeTopUpPurchased: user.freeTopUpPurchased,
totalAvailable: remaining + extraCredits,
resetsAt: user.quotaResetAt.toISOString(),
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
plan: user.plan,
subscriptionStatus: hasProFeatures(user.plan) ? ("pro" as const) : ("free" as const),
createdVideoCount: user.createdVideoCount, createdVideoCount: user.createdVideoCount,
watermarkFreeRemaining: hasProFeatures(user.plan) maxBatchSize: APP_LIMITS.maxBatchSize,
? null maxResolutionHeight: APP_LIMITS.maxResolutionHeight,
: watermarkFreeRendersRemaining(user.createdVideoCount), watermarkOptional: true,
maxBatchSize: limits.maxBatchSize, customWatermark: true,
watermarkOptional: limits.watermarkOptional, perItemImages: true,
customWatermark: limits.customWatermark, artTrackLayouts: true,
perItemImages: limits.perItemImages, selfHosted: true,
artTrackLayouts: limits.artTrackLayouts, unlimited: true,
maxResolutionHeight: limits.maxResolutionHeight,
selfHosted: isSelfHostedEdition(),
}; };
} }
export type ReservationSplit = {
fromQuota: number;
fromCredits: number;
};
/**
* Monthly first, then never-expiring extras for both FREE and PREMIUM.
*/
export function planReservation(
_plan: Plan,
remainingQuota: number,
extraCredits: number,
count: number,
): ReservationSplit | null {
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
const fromQuota = Math.min(count, Math.max(0, remainingQuota));
const fromCredits = count - fromQuota;
if (fromCredits > extraCredits) return null;
return { fromQuota, fromCredits };
}
export async function checkQuota(userId: string, requestedCount: number) { export async function checkQuota(userId: string, requestedCount: number) {
if (isSelfHostedEdition()) {
const info = await getQuotaInfo(userId); const info = await getQuotaInfo(userId);
if (requestedCount > info.maxBatchSize) { if (requestedCount > info.maxBatchSize) {
return { return {
ok: false as const, ok: false as const,
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`, error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
used: info.used,
limit: info.limit,
remaining: info.remaining,
videoCredits: 0,
extraCredits: 0,
resetsAt: info.resetsAt,
};
}
return {
ok: true as const,
...info, ...info,
reservation: { fromQuota: requestedCount, fromCredits: 0 },
}; };
} }
const user = await ensureQuotaReset(userId); return { ok: true as const, ...info };
const limits = getPlanLimits(user.plan);
const limit = user.monthlyCredits + (user.plan === "PREMIUM" ? user.bonusQuota : 0);
const remaining = Math.max(0, limit - user.videosUsed);
if (requestedCount > limits.maxBatchSize) {
return {
ok: false as const,
error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
used: user.videosUsed,
limit,
remaining,
videoCredits: user.extraCredits,
extraCredits: user.extraCredits,
resetsAt: user.quotaResetAt.toISOString(),
};
}
const split = planReservation(user.plan, remaining, user.extraCredits, requestedCount);
if (!split) {
return {
ok: false as const,
error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.extraCredits),
used: user.videosUsed,
limit,
remaining,
videoCredits: user.extraCredits,
extraCredits: user.extraCredits,
resetsAt: user.quotaResetAt.toISOString(),
};
}
const info = await getQuotaInfo(userId);
return { ok: true as const, ...info, reservation: split };
} }
/** export async function reserveQuota(userId: string, count: number) {
* Atomically reserve monthly credits first, then extras. if (count > APP_LIMITS.maxBatchSize) {
*/ throw new Error(`Batch limit exceeded. Max ${APP_LIMITS.maxBatchSize} files per batch.`);
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
if (isSelfHostedEdition()) {
const limits = getPlanLimits("FREE");
if (count > limits.maxBatchSize) {
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
}
await ensureQuotaReset(userId);
await prisma.user.update({
where: { id: userId },
data: { videosUsed: { increment: count } },
});
return { fromQuota: count, fromCredits: 0 };
}
await ensureQuotaReset(userId);
const limits = getPlanLimits(
(await prisma.user.findUniqueOrThrow({ where: { id: userId } })).plan,
);
if (count > limits.maxBatchSize) {
throw new Error(
`Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
);
}
try {
const result = await deductUserCredit(userId, count);
return { fromQuota: result.fromMonthly, fromCredits: result.fromExtra };
} catch (err) {
if (err instanceof CreditInsufficientError) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
throw new Error(
getQuotaExceededMessage(user.plan, user.quotaResetAt, user.extraCredits),
);
}
throw err;
} }
} await prisma.user.findUniqueOrThrow({ where: { id: userId } });
export async function releaseQuota(userId: string, count: number) {
if (count <= 0) return;
await refundUserCredit(userId, count, 0);
}
export async function releaseCredits(userId: string, count: number) {
if (count <= 0) return;
await refundUserCredit(userId, 0, count);
}
export async function releaseReservation(
userId: string,
billingSource: "QUOTA" | "CREDIT",
count = 1,
) {
if (billingSource === "CREDIT") {
await releaseCredits(userId, count);
} else {
await releaseQuota(userId, count);
}
}
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits);
}
/** @deprecated Prefer reserveQuota at create */
export async function incrementQuota(userId: string, count: number) {
await reserveQuota(userId, count);
}
export function getInitialQuotaResetAt(): Date {
return getNextMonthlyQuotaReset();
} }
-71
View File
@@ -1,71 +0,0 @@
import type Stripe from "stripe";
import { STRIPE_PRODUCT_TAX_CODE } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getStripe } from "@/lib/stripe";
/** Enable Stripe Tax only when Dashboard registrations are active (STRIPE_AUTOMATIC_TAX=true). */
export function isStripeAutomaticTaxEnabled(): boolean {
return process.env.STRIPE_AUTOMATIC_TAX === "true";
}
/**
* Shared Checkout Session options required/recommended by Stripe for SaaS:
* - client_reference_id for linking
* - automatic_tax (opt-in) + customer_update.address when tax is on
* - tax_code already set on product_data by callers
*/
export function checkoutTaxAndReferenceOptions(userId: string): Partial<Stripe.Checkout.SessionCreateParams> {
const opts: Partial<Stripe.Checkout.SessionCreateParams> = {
client_reference_id: userId,
};
if (isStripeAutomaticTaxEnabled()) {
opts.automatic_tax = { enabled: true };
opts.customer_update = { address: "auto", name: "auto" };
// Collect billing address so tax can be calculated for new/returning customers
opts.billing_address_collection = "required";
}
return opts;
}
/** tax_behavior required on Prices when automatic tax is enabled. */
export function priceDataTaxFields(): { tax_behavior?: Stripe.Checkout.SessionCreateParams.LineItem.PriceData["tax_behavior"] } {
if (!isStripeAutomaticTaxEnabled()) return {};
// Exclusive: listed prices are pre-tax; VAT/GST added at checkout
return { tax_behavior: "exclusive" };
}
export function productDataWithTaxCode(
name: string,
description: string,
): Stripe.Checkout.SessionCreateParams.LineItem.PriceData.ProductData {
return {
name,
description,
tax_code: STRIPE_PRODUCT_TAX_CODE,
};
}
/** Ensure the app user has a Stripe Customer and return its id. */
export async function ensureStripeCustomer(userId: string, email: string): Promise<string> {
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { stripeCustomerId: true },
});
if (dbUser.stripeCustomerId) return dbUser.stripeCustomerId;
const stripe = getStripe();
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
await prisma.user.update({
where: { id: userId },
data: { stripeCustomerId: customer.id },
});
return customer.id;
}
-30
View File
@@ -1,30 +0,0 @@
import Stripe from "stripe";
let stripe: Stripe | null = null;
export function getStripe(): Stripe {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error("STRIPE_SECRET_KEY is not configured");
}
if (!stripe) {
stripe = new Stripe(key);
}
return stripe;
}
export function isStripeConfigured(): boolean {
return Boolean(process.env.STRIPE_SECRET_KEY);
}
/**
* Bypass Stripe Checkout when:
* - BILLING_DEV_MOCK=true, or
* - development and no STRIPE_SECRET_KEY (so UI still works without stripe listen)
* Set BILLING_DEV_MOCK=false to force real Stripe even without keys (will 503).
*/
export function isBillingDevMock(): boolean {
if (process.env.BILLING_DEV_MOCK === "true") return true;
if (process.env.BILLING_DEV_MOCK === "false") return false;
return process.env.NODE_ENV === "development" && !process.env.STRIPE_SECRET_KEY;
}
+5 -13
View File
@@ -1,6 +1,3 @@
import { Plan } from "@prisma/client";
import { hasProFeatures } from "./edition";
export type TitleFields = { export type TitleFields = {
/** YouTube video title */ /** YouTube video title */
title?: string | null; title?: string | null;
@@ -9,18 +6,16 @@ export type TitleFields = {
artist?: string | null; artist?: string | null;
}; };
/** Resolve the title sent to YouTube. Pro may omit video title → `${artist} - ${songTitle}`. */ /** Resolve the title sent to YouTube, falling back to artist and song metadata. */
export function resolveYouTubeTitle(meta: TitleFields, plan: Plan): string { export function resolveYouTubeTitle(meta: TitleFields): string {
const videoTitle = meta.title?.trim(); const videoTitle = meta.title?.trim();
if (videoTitle) return videoTitle; if (videoTitle) return videoTitle;
if (hasProFeatures(plan)) {
const artist = meta.artist?.trim(); const artist = meta.artist?.trim();
const song = meta.songTitle?.trim(); const song = meta.songTitle?.trim();
if (artist && song) return `${artist} - ${song}`; if (artist && song) return `${artist} - ${song}`;
if (song) return song; if (song) return song;
if (artist) return artist; if (artist) return artist;
}
return ""; return "";
} }
@@ -33,13 +28,10 @@ export function resolveBurnedSongTitle(
return meta.songTitle?.trim() || fallback; return meta.songTitle?.trim() || fallback;
} }
export function validateYouTubeTitle(meta: TitleFields, plan: Plan): string | null { export function validateYouTubeTitle(meta: TitleFields): string | null {
const resolved = resolveYouTubeTitle(meta, plan); const resolved = resolveYouTubeTitle(meta);
if (!resolved) { if (!resolved) {
if (hasProFeatures(plan)) { return "Enter a video title, song title, or artist";
return "Enter a video title, or both artist and song title for the YouTube fallback";
}
return "Each video must have a video title";
} }
return null; return null;
} }
+7 -7
View File
@@ -13,23 +13,23 @@ export type ItemMetadata = {
madeForKids: boolean; madeForKids: boolean;
embeddable: boolean; embeddable: boolean;
creativeCommons: boolean; creativeCommons: boolean;
/** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */ /** Legacy toggle default Songs2VID branding when watermark.mode is omitted. */
includeWatermark: boolean; includeWatermark: boolean;
/** YouTube playlist ID (Pro only). Video is added after upload. */ /** YouTube playlist ID. Video is added after upload. */
playlistId?: string | null; playlistId?: string | null;
/** /**
* Optional per-item cover image path (Pro / perItemImages). * Optional per-item cover image path.
* When omitted, the job-level shared imagePath is used. * When omitted, the job-level shared imagePath is used.
*/ */
imagePath?: string | null; imagePath?: string | null;
/** Pro custom branding. Free may only use mode none|default at bottom-right. */ /** Custom branding watermark settings. */
watermark?: Partial<WatermarkSettings> | null; watermark?: Partial<WatermarkSettings> | null;
/** Artist line for art-track layouts (Pro, on-video only). */ /** Artist line for art-track layouts (on-video only). */
artist?: string | null; artist?: string | null;
/** Song / track title burned into art-track layouts (Pro, on-video only). */ /** Song / track title burned into art-track layouts (on-video only). */
songTitle?: string | null; songTitle?: string | null;
/** /**
* Pro art-track layout. Prefer camelCase; snake_case aliases accepted: * Art-track layout. Prefer camelCase; snake_case aliases accepted:
* layout_template, blur_amount, text_padding. * layout_template, blur_amount, text_padding.
*/ */
layout?: Partial<LayoutSettings> & { layout?: Partial<LayoutSettings> & {
+2 -66
View File
@@ -1,79 +1,15 @@
import path from "path"; import path from "path";
import type { Plan } from "@prisma/client";
import { hasProFeatures } from "./edition";
import { import {
DEFAULT_WATERMARK,
normalizeWatermarkSettings, normalizeWatermarkSettings,
type WatermarkSettings, type WatermarkSettings,
} from "./watermark"; } from "./watermark";
/** Free users get this many lifetime watermark-free successful videos. */ /** Preserve the user's optional watermark choice without tier-based forcing. */
export const FREE_UNWATERMARKED_VIDEO_LIMIT = 2;
export type SubscriptionStatusLabel = "free" | "pro";
export function subscriptionStatusFromPlan(plan: Plan): SubscriptionStatusLabel {
return hasProFeatures(plan) ? "pro" : "free";
}
/**
* Whether the Songs2VID brand watermark must be applied for this render slot.
* Pro / self-hosted: never forced. Free: forced after FREE_UNWATERMARKED_VIDEO_LIMIT.
*/
export function shouldApplyBrandWatermark(options: {
plan: Plan;
createdVideoCount: number;
/** 0-based index within the current batch (accounts for multi-file jobs). */
itemIndex?: number;
}): boolean {
if (hasProFeatures(options.plan)) return false;
const slot = options.createdVideoCount + (options.itemIndex ?? 0);
return slot >= FREE_UNWATERMARKED_VIDEO_LIMIT;
}
/**
* Apply free/pro brand-watermark policy to normalized settings.
* - Pro: strip default brand watermark (custom text/logo kept).
* - Free under limit: force mode `none`.
* - Free at/over limit: force default brand overlay (bottom-right).
*/
export function applyBrandWatermarkPolicy( export function applyBrandWatermarkPolicy(
input: Partial<WatermarkSettings> | null | undefined, input: Partial<WatermarkSettings> | null | undefined,
includeWatermarkFallback: boolean, includeWatermarkFallback: boolean,
options: {
plan: Plan;
createdVideoCount: number;
itemIndex?: number;
},
): WatermarkSettings { ): WatermarkSettings {
const base = normalizeWatermarkSettings(input, includeWatermarkFallback); return normalizeWatermarkSettings(input, includeWatermarkFallback);
if (hasProFeatures(options.plan)) {
if (base.mode === "default") {
return { ...base, mode: "none" };
}
return base;
}
if (shouldApplyBrandWatermark(options)) {
return {
...DEFAULT_WATERMARK,
mode: "default",
position: "bottom-right",
offsetX: 20,
offsetY: 20,
};
}
return {
...base,
mode: "none",
};
} }
export function watermarkFreeRendersRemaining(createdVideoCount: number): number {
return Math.max(0, FREE_UNWATERMARKED_VIDEO_LIMIT - createdVideoCount);
}
/** Absolute path to the Songs2VID brand watermark asset (PNG). */
export const WATERMARK_FILE_PATH = path.join(process.cwd(), "assets", "watermark.png"); export const WATERMARK_FILE_PATH = path.join(process.cwd(), "assets", "watermark.png");
+2 -3
View File
@@ -30,7 +30,7 @@ export type WatermarkSettings = {
offsetX: number; offsetX: number;
offsetY: number; offsetY: number;
/** /**
* Typography (Pro / text mode). * Typography (text mode).
* `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath. * `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath.
*/ */
fontKey?: WatermarkFontKey; fontKey?: WatermarkFontKey;
@@ -178,7 +178,7 @@ export function normalizeWatermarkSettings(
}; };
} }
/** True when settings go beyond Free-tier default branding toggle. */ /** True when settings go beyond the default branding toggle. */
export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean { export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean {
if (settings.mode === "text" || settings.mode === "logo") return true; if (settings.mode === "text" || settings.mode === "logo") return true;
if (settings.mode === "none") return false; if (settings.mode === "none") return false;
@@ -189,4 +189,3 @@ export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings):
return false; return false;
} }
export const PREMIUM_REQUIRED_CODE = "PREMIUM_REQUIRED" as const;
-4
View File
@@ -4,10 +4,6 @@ function resolveDocsUrl(): string {
if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) { if (process.env.NEXT_PUBLIC_DOCS_URL?.trim()) {
return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, ""); return process.env.NEXT_PUBLIC_DOCS_URL.replace(/\/$/, "");
} }
const auth = process.env.NEXTAUTH_URL ?? "";
if (/localhost|127\.0\.0\.1/i.test(auth)) {
return "http://localhost:3001";
}
return "https://docs.songs2vid.com"; return "https://docs.songs2vid.com";
} }
+8 -27
View File
@@ -1,11 +1,11 @@
{ {
"name": "songs2vid", "name": "songs2vid-oss",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "songs2vid", "name": "songs2vid-oss",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
@@ -19,8 +19,7 @@
"next": "^15.1.3", "next": "^15.1.3",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"stripe": "^22.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
@@ -216,9 +215,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1887,9 +1886,8 @@
"version": "22.20.1", "version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -7690,23 +7688,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/stripe": {
"version": "22.3.2",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.2.tgz",
"integrity": "sha512-O13QOvgEIQvDlTy6Ubb5kB980wpbhmoZNsgCXKILjCMZS67f+bW+6w99k3gnSi/N1lkryoj1WYdpGT5Wc5edjg==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/strtok3": { "node_modules/strtok3": {
"version": "10.3.5", "version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
@@ -8274,7 +8255,7 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
+5 -8
View File
@@ -1,20 +1,18 @@
{ {
"name": "songs2vid", "name": "songs2vid-oss",
"description": "Payment-free self-hosted audio-to-video app with YouTube upload",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"dev:all": "concurrently -n web,worker,docs -c blue,green,magenta \"npm run dev\" \"npm run worker\" \"npm run docs:dev\"", "dev:all": "concurrently -n web,worker -c blue,green \"npm run dev\" \"npm run worker\"",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"worker": "tsx --env-file=.env worker/index.ts", "worker": "tsx --env-file=.env worker/index.ts",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push"
"docs:dev": "npm run start --prefix website -- --port 3001",
"docs:build": "npm run build --prefix website",
"docs:serve": "npm run serve --prefix website -- --port 3001"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
@@ -28,8 +26,7 @@
"next": "^15.1.3", "next": "^15.1.3",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"stripe": "^22.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
@@ -1,30 +1,19 @@
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PREMIUM');
-- CreateEnum
CREATE TYPE "Privacy" AS ENUM ('PUBLIC', 'PRIVATE', 'UNLISTED'); CREATE TYPE "Privacy" AS ENUM ('PUBLIC', 'PRIVATE', 'UNLISTED');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'PARTIAL'); CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'PARTIAL');
-- CreateEnum
CREATE TYPE "JobItemStatus" AS ENUM ('PENDING', 'ENCODING', 'UPLOADING', 'COMPLETED', 'FAILED'); CREATE TYPE "JobItemStatus" AS ENUM ('PENDING', 'ENCODING', 'UPLOADING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "User" ( CREATE TABLE "User" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"email" TEXT NOT NULL, "email" TEXT NOT NULL,
"name" TEXT, "name" TEXT,
"image" TEXT, "image" TEXT,
"plan" "Plan" NOT NULL DEFAULT 'FREE', "createdVideoCount" INTEGER NOT NULL DEFAULT 0,
"videosUsed" INTEGER NOT NULL DEFAULT 0, "apiKeyHash" TEXT,
"quotaResetAt" TIMESTAMP(3) NOT NULL, "apiKeyPrefix" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") CONSTRAINT "User_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "YouTubeConnection" ( CREATE TABLE "YouTubeConnection" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
@@ -33,11 +22,9 @@ CREATE TABLE "YouTubeConnection" (
"expiresAt" TIMESTAMP(3) NOT NULL, "expiresAt" TIMESTAMP(3) NOT NULL,
"channelId" TEXT NOT NULL, "channelId" TEXT NOT NULL,
"channelTitle" TEXT NOT NULL, "channelTitle" TEXT NOT NULL,
CONSTRAINT "YouTubeConnection_pkey" PRIMARY KEY ("id") CONSTRAINT "YouTubeConnection_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "Job" ( CREATE TABLE "Job" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
@@ -45,11 +32,9 @@ CREATE TABLE "Job" (
"imagePath" TEXT NOT NULL, "imagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMP(3), "completedAt" TIMESTAMP(3),
CONSTRAINT "Job_pkey" PRIMARY KEY ("id") CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "JobItem" ( CREATE TABLE "JobItem" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"jobId" TEXT NOT NULL, "jobId" TEXT NOT NULL,
@@ -65,26 +50,39 @@ CREATE TABLE "JobItem" (
"madeForKids" BOOLEAN NOT NULL DEFAULT false, "madeForKids" BOOLEAN NOT NULL DEFAULT false,
"embeddable" BOOLEAN NOT NULL DEFAULT true, "embeddable" BOOLEAN NOT NULL DEFAULT true,
"creativeCommons" BOOLEAN NOT NULL DEFAULT false, "creativeCommons" BOOLEAN NOT NULL DEFAULT false,
"includeWatermark" BOOLEAN NOT NULL DEFAULT true, "includeWatermark" BOOLEAN NOT NULL DEFAULT false,
"itemImagePath" TEXT,
"watermarkMode" TEXT NOT NULL DEFAULT 'none',
"watermarkText" TEXT,
"watermarkLogoPath" TEXT,
"watermarkFontKey" TEXT,
"watermarkFontPath" TEXT,
"watermarkPosition" TEXT NOT NULL DEFAULT 'bottom-right',
"watermarkOffsetX" INTEGER NOT NULL DEFAULT 20,
"watermarkOffsetY" INTEGER NOT NULL DEFAULT 20,
"artist" TEXT,
"songTitle" TEXT,
"layoutTemplate" TEXT,
"blurAmount" INTEGER NOT NULL DEFAULT 55,
"blurOpacity" INTEGER NOT NULL DEFAULT 100,
"textPadding" INTEGER NOT NULL DEFAULT 48,
"titleArtistGap" INTEGER NOT NULL DEFAULT 10,
"textOffsetX" INTEGER NOT NULL DEFAULT 0,
"textOffsetY" INTEGER NOT NULL DEFAULT 0,
"playlistId" TEXT,
"status" "JobItemStatus" NOT NULL DEFAULT 'PENDING', "status" "JobItemStatus" NOT NULL DEFAULT 'PENDING',
"outputPath" TEXT, "outputPath" TEXT,
"youtubeVideoId" TEXT, "youtubeVideoId" TEXT,
"error" TEXT, "error" TEXT,
CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id") CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_apiKeyHash_key" ON "User"("apiKeyHash");
-- CreateIndex
CREATE UNIQUE INDEX "YouTubeConnection_userId_key" ON "YouTubeConnection"("userId"); CREATE UNIQUE INDEX "YouTubeConnection_userId_key" ON "YouTubeConnection"("userId");
ALTER TABLE "YouTubeConnection" ADD CONSTRAINT "YouTubeConnection_userId_fkey"
-- AddForeignKey FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "YouTubeConnection" ADD CONSTRAINT "YouTubeConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey ALTER TABLE "JobItem" ADD CONSTRAINT "JobItem_jobId_fkey"
ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JobItem" ADD CONSTRAINT "JobItem_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "cardLast4" TEXT,
ADD COLUMN "subscribedAt" TIMESTAMP(3);
@@ -1,21 +0,0 @@
-- CreateEnum
CREATE TYPE "QuotaExtensionRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "bonusQuota" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "QuotaExtensionRequest" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "QuotaExtensionRequestStatus" NOT NULL DEFAULT 'PENDING',
"message" TEXT NOT NULL DEFAULT '',
"requestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
"adminNote" TEXT,
CONSTRAINT "QuotaExtensionRequest_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "QuotaExtensionRequest" ADD CONSTRAINT "QuotaExtensionRequest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,6 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "apiKeyHash" TEXT,
ADD COLUMN "apiKeyPrefix" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_apiKeyHash_key" ON "User"("apiKeyHash");
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "playlistId" TEXT;
@@ -1,8 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "apiRateLimitBonus" INTEGER NOT NULL DEFAULT 0;
-- CreateEnum
CREATE TYPE "QuotaExtensionKind" AS ENUM ('VIDEO_QUOTA', 'API_RATE_LIMIT');
-- AlterTable
ALTER TABLE "QuotaExtensionRequest" ADD COLUMN "kind" "QuotaExtensionKind" NOT NULL DEFAULT 'VIDEO_QUOTA';
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "songTitle" TEXT;

Some files were not shown because too many files have changed in this diff Show More