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:
+9
-41
@@ -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 1–15 credit top-ups @ €0.25)
|
|
||||||
# STRIPE_SECRET_KEY="sk_test_..."
|
|
||||||
# Prefer a restricted key (rk_...) with Checkout + Customers + Billing Portal + Webhooks only
|
|
||||||
# STRIPE_WEBHOOK_SECRET="whsec_..."
|
|
||||||
# Optional Dashboard Price ID for Pro (otherwise inline price_data €5/mo is used)
|
|
||||||
# STRIPE_PRO_PRICE_ID="price_..."
|
|
||||||
# Collect VAT/GST via Stripe Tax AFTER adding Tax registrations in Dashboard:
|
|
||||||
# STRIPE_AUTOMATIC_TAX="true"
|
|
||||||
# Force local mock upgrades/grants without Stripe: BILLING_DEV_MOCK=true
|
|
||||||
# Disable auto-mock when Stripe key missing: BILLING_DEV_MOCK=false
|
|
||||||
# Point Stripe webhook to: POST /api/stripe/webhook
|
|
||||||
# Events (required):
|
|
||||||
# checkout.session.completed
|
|
||||||
# invoice.payment_succeeded
|
|
||||||
# invoice.payment_failed
|
|
||||||
# customer.subscription.updated
|
|
||||||
# customer.subscription.deleted
|
|
||||||
# Dashboard: enable Customer portal (Settings → Billing → Customer portal)
|
|
||||||
# Optional: Managed Payments (MoR) enable in Dashboard + set managed_payments on Checkout if you use it
|
|
||||||
# Dev helpers: POST /api/dev/grant-credits { "action": "set-pro" | "free-top-up" | "grant-credits" }
|
|
||||||
# Admin reset: POST /api/admin/reset-credits Authorization: Bearer $ADMIN_API_KEY
|
|
||||||
# See also /admin for footer badge management
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import {
|
|
||||||
CREDIT_PRICE_CENTS,
|
|
||||||
FREE_EXTRA_CREDITS_MAX,
|
|
||||||
FREE_TOP_UP_MAX,
|
|
||||||
FREE_TOP_UP_MIN,
|
|
||||||
formatCreditPrice,
|
|
||||||
validateFreeTopUpAmount,
|
|
||||||
} from "@/lib/credits";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { getSessionUser } from "@/lib/session";
|
|
||||||
import {
|
|
||||||
checkoutTaxAndReferenceOptions,
|
|
||||||
ensureStripeCustomer,
|
|
||||||
priceDataTaxFields,
|
|
||||||
productDataWithTaxCode,
|
|
||||||
} from "@/lib/stripe-checkout";
|
|
||||||
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
|
||||||
import { grantExtraCredits } from "@/lib/billing";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const user = await getSessionUser();
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: {
|
|
||||||
extraCredits: true,
|
|
||||||
plan: true,
|
|
||||||
monthlyCredits: true,
|
|
||||||
videosUsed: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const purchases = await prisma.creditPurchase.findMany({
|
|
||||||
where: { userId: user.id, status: "COMPLETED" },
|
|
||||||
orderBy: { completedAt: "desc" },
|
|
||||||
take: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const cap = FREE_EXTRA_CREDITS_MAX;
|
|
||||||
const room = Math.max(0, cap - dbUser.extraCredits);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
extraCredits: dbUser.extraCredits,
|
|
||||||
videoCredits: dbUser.extraCredits,
|
|
||||||
topUpMin: FREE_TOP_UP_MIN,
|
|
||||||
topUpMax: FREE_TOP_UP_MAX,
|
|
||||||
priceCentsPerCredit: CREDIT_PRICE_CENTS,
|
|
||||||
priceLabelPerCredit: formatCreditPrice(1),
|
|
||||||
creditBalanceMax: cap,
|
|
||||||
monthlyCredits: dbUser.monthlyCredits,
|
|
||||||
creditsUsed: dbUser.videosUsed,
|
|
||||||
plan: dbUser.plan,
|
|
||||||
stripeConfigured: isStripeConfigured() || isBillingDevMock(),
|
|
||||||
room,
|
|
||||||
atExtraCap: room === 0,
|
|
||||||
purchases: purchases.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
credits: p.credits,
|
|
||||||
amountCents: p.amountCents,
|
|
||||||
completedAt: p.completedAt?.toISOString() ?? null,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Free tier: top up 1–15 extra credits (price = credits × €0.25). */
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const user = await getSessionUser();
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.plan === "PREMIUM") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
"Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.",
|
|
||||||
},
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let credits = 0;
|
|
||||||
try {
|
|
||||||
const body = await req.json();
|
|
||||||
credits = Math.floor(Number(body.credits));
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbUser = await prisma.user.findUniqueOrThrow({
|
|
||||||
where: { id: user.id },
|
|
||||||
select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const monthlyRemaining = Math.max(
|
|
||||||
0,
|
|
||||||
dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed,
|
|
||||||
);
|
|
||||||
const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining);
|
|
||||||
if (!validation.ok) {
|
|
||||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { credits: amount, amountCents } = validation;
|
|
||||||
|
|
||||||
if (isBillingDevMock()) {
|
|
||||||
const purchase = await prisma.creditPurchase.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
credits: amount,
|
|
||||||
amountCents,
|
|
||||||
status: "COMPLETED",
|
|
||||||
completedAt: new Date(),
|
|
||||||
stripeSessionId: `dev_topup_${Date.now()}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await grantExtraCredits(user.id, amount);
|
|
||||||
return NextResponse.json({
|
|
||||||
mocked: true,
|
|
||||||
granted: amount,
|
|
||||||
amountCents,
|
|
||||||
purchaseId: purchase.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStripeConfigured()) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
|
|
||||||
{ status: 503 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
|
|
||||||
const purchase = await prisma.creditPurchase.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
credits: amount,
|
|
||||||
amountCents,
|
|
||||||
status: "PENDING",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stripe = getStripe();
|
|
||||||
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
|
||||||
mode: "payment",
|
|
||||||
customer: customerId,
|
|
||||||
line_items: [
|
|
||||||
{
|
|
||||||
quantity: amount,
|
|
||||||
price_data: {
|
|
||||||
currency: "eur",
|
|
||||||
unit_amount: CREDIT_PRICE_CENTS,
|
|
||||||
...priceDataTaxFields(),
|
|
||||||
product_data: productDataWithTaxCode(
|
|
||||||
"Songs2VID video credit",
|
|
||||||
`1 credit = 1 video upload (${formatCreditPrice(1)} each)`,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
metadata: {
|
|
||||||
type: "free_top_up",
|
|
||||||
userId: user.id,
|
|
||||||
purchaseId: purchase.id,
|
|
||||||
credits: String(amount),
|
|
||||||
},
|
|
||||||
...checkoutTaxAndReferenceOptions(user.id),
|
|
||||||
success_url: `${origin}/dashboard/settings?credits=success`,
|
|
||||||
cancel_url: `${origin}/dashboard/settings?credits=cancelled`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.creditPurchase.update({
|
|
||||||
where: { id: purchase.id },
|
|
||||||
data: { stripeSessionId: session.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ url: session.url, sessionId: session.id });
|
|
||||||
} catch (err) {
|
|
||||||
await prisma.creditPurchase.update({
|
|
||||||
where: { id: purchase.id },
|
|
||||||
data: { status: "FAILED" },
|
|
||||||
});
|
|
||||||
const message = err instanceof Error ? err.message : "Checkout failed";
|
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 +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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
+32
-180
@@ -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,174 +39,51 @@ 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>
|
||||||
|
|
||||||
<div className="mt-6 border-t border-gray-800 pt-6">
|
|
||||||
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
|
||||||
YouTube
|
|
||||||
</h3>
|
|
||||||
{youtube ? (
|
|
||||||
<dl className="space-y-3 text-sm">
|
|
||||||
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
|
||||||
<InfoRow label="Channel ID" value={youtube.channelId} />
|
|
||||||
</dl>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{channelUrl && (
|
|
||||||
<a
|
|
||||||
href={channelUrl}
|
|
||||||
target="_blank"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Go to your channel
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-4 text-sm text-gray-400">
|
|
||||||
To refresh your YouTube permissions,{" "}
|
|
||||||
<Link href="/" className="text-accent hover:underline">
|
|
||||||
sign out and sign in again
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</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 & billing</h2>
|
<h2 className="mb-4 text-lg font-semibold text-white">YouTube</h2>
|
||||||
|
{youtube ? (
|
||||||
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
<dl className="space-y-3 text-sm">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<InfoRow label="Channel name" value={youtube.channelTitle} />
|
||||||
<div>
|
<InfoRow label="Channel ID" value={youtube.channelId} />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
</dl>
|
||||||
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,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<p className="text-sm text-red-400">YouTube account not connected.</p>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
{channelUrl && (
|
||||||
|
<a
|
||||||
|
href={channelUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-sm text-gray-400">
|
||||||
|
To refresh YouTube permissions,{" "}
|
||||||
|
<Link href="/" className="text-accent hover:underline">sign out and sign in again</Link>.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
|
<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">API key</h2>
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
<ApiKeySettings initialStatus={apiKeyStatus} initialRateLimit={apiRateLimit} />
|
||||||
<ApiKeySettings
|
</section>
|
||||||
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 & data</h2>
|
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & 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
@@ -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>
|
||||||
|
|||||||
+14
-81
@@ -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
|
|
||||||
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 />
|
|
||||||
<p className="max-w-md text-xs leading-relaxed text-gray-400">
|
|
||||||
By clicking Continue with Google, you accept our{" "}
|
|
||||||
<Link
|
|
||||||
href="/terms"
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<SignInButton large />
|
||||||
|
<p className="mt-5 text-xs leading-relaxed text-gray-500">
|
||||||
<StepsSection />
|
By continuing, you accept the{" "}
|
||||||
<BenefitsSection />
|
<Link href="/terms" className="hover:text-gray-300">Terms</Link> and{" "}
|
||||||
|
<Link href="/privacy" className="hover:text-gray-300">Privacy Policy</Link>.
|
||||||
<DownloadSection />
|
</p>
|
||||||
|
</div>
|
||||||
<PricingSection />
|
|
||||||
|
|
||||||
<SupportSection />
|
|
||||||
<Footer />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-304
@@ -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} ("we", "us")
|
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'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'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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, 1–15 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
@@ -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 ("Terms") 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 "Service") operated by{" "}
|
each instance is responsible for availability, configuration, and user access.
|
||||||
{LEGAL_OPERATOR.legalName} ("we", "us"). 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'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 "Made for Kids" 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'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 & 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 & 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 "AS IS" AND "AS AVAILABLE" TO THE MAXIMUM EXTENT
|
|
||||||
PERMITTED BY LAW. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT ENCODING,
|
|
||||||
UPLOADS, OR METADATA TRANSFER WILL BE ERROR-FREE OR UNINTERRUPTED.
|
|
||||||
</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.
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+29
-221
@@ -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 = {
|
|
||||||
configured: boolean;
|
|
||||||
prefix: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RateLimitStatus = {
|
|
||||||
limit: number;
|
|
||||||
used: number;
|
|
||||||
remaining: number;
|
|
||||||
windowSeconds: number;
|
|
||||||
resetsInSeconds: number;
|
|
||||||
bonus?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = {
|
type Props = {
|
||||||
initialStatus: ApiKeyStatus;
|
initialStatus: { configured: boolean; prefix: string | null };
|
||||||
initialRateLimit: RateLimitStatus;
|
initialRateLimit: {
|
||||||
initialExtensionUsage: ExtensionUsage;
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
windowSeconds: number;
|
||||||
|
resetsInSeconds: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ApiKeySettings({
|
export function ApiKeySettings({ initialStatus, initialRateLimit }: Props) {
|
||||||
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 className="text-xs text-gray-500">
|
||||||
|
Rate limit: {initialRateLimit.limit} requests per {initialRateLimit.windowSeconds} seconds.
|
||||||
</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">
|
|
||||||
{rateLimit.remaining} requests remaining · window {rateLimit.windowSeconds}s
|
|
||||||
</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>
|
<a
|
||||||
|
href={API_DOCS_URL}
|
||||||
<div className="rounded border border-gray-800 bg-surface p-4 text-xs text-gray-400">
|
target="_blank"
|
||||||
<p className="mb-2 font-semibold uppercase tracking-wider text-gray-500">Endpoints</p>
|
rel="noopener noreferrer"
|
||||||
<ul className="space-y-2 font-mono">
|
className="rounded border border-gray-600 px-4 py-2 text-sm text-gray-300 hover:text-white"
|
||||||
<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>
|
API documentation
|
||||||
<li>POST /api/v1/jobs/batch: small packs only (one-shot multipart)</li>
|
</a>
|
||||||
<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
|
|
||||||
href={API_DOCS_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
Full API docs
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 1–15 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 1–15 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 1–15 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,
|
||||||
|
|||||||
@@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 1–15 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
@@ -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
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+25
-65
@@ -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
|
item.metadata,
|
||||||
? resolveBurnedSongTitle(
|
filenameWithoutExtension(item.audioFilename),
|
||||||
item.metadata,
|
);
|
||||||
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,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,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
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+23
-266
@@ -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);
|
|
||||||
if (requestedCount > info.maxBatchSize) {
|
|
||||||
return {
|
|
||||||
ok: false as const,
|
|
||||||
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,
|
|
||||||
reservation: { fromQuota: requestedCount, fromCredits: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await ensureQuotaReset(userId);
|
|
||||||
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);
|
const info = await getQuotaInfo(userId);
|
||||||
return { ok: true as const, ...info, reservation: split };
|
if (requestedCount > info.maxBatchSize) {
|
||||||
}
|
return {
|
||||||
|
ok: false as const,
|
||||||
/**
|
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
|
||||||
* Atomically reserve monthly credits first, then extras.
|
...info,
|
||||||
*/
|
};
|
||||||
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);
|
return { ok: true as const, ...info };
|
||||||
|
}
|
||||||
|
|
||||||
const limits = getPlanLimits(
|
export async function reserveQuota(userId: string, count: number) {
|
||||||
(await prisma.user.findUniqueOrThrow({ where: { id: userId } })).plan,
|
if (count > APP_LIMITS.maxBatchSize) {
|
||||||
);
|
throw new Error(`Batch limit exceeded. Max ${APP_LIMITS.maxBatchSize} files per batch.`);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+10
-18
@@ -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
@@ -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
@@ -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
@@ -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,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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+8
-27
@@ -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
@@ -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,90 +1,88 @@
|
|||||||
-- 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,
|
||||||
"accessToken" TEXT NOT NULL,
|
"accessToken" TEXT NOT NULL,
|
||||||
"refreshToken" TEXT NOT NULL,
|
"refreshToken" TEXT NOT NULL,
|
||||||
"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,
|
||||||
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
|
"status" "JobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
"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,
|
||||||
"audioPath" TEXT NOT NULL,
|
"audioPath" TEXT NOT NULL,
|
||||||
"audioFilename" TEXT NOT NULL,
|
"audioFilename" TEXT NOT NULL,
|
||||||
"title" TEXT NOT NULL,
|
"title" TEXT NOT NULL,
|
||||||
"description" TEXT NOT NULL DEFAULT '',
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
"tags" TEXT NOT NULL DEFAULT '',
|
"tags" TEXT NOT NULL DEFAULT '',
|
||||||
"privacy" "Privacy" NOT NULL DEFAULT 'PUBLIC',
|
"privacy" "Privacy" NOT NULL DEFAULT 'PUBLIC',
|
||||||
"categoryId" TEXT NOT NULL DEFAULT '10',
|
"categoryId" TEXT NOT NULL DEFAULT '10',
|
||||||
"resolution" TEXT NOT NULL DEFAULT '1280x720',
|
"resolution" TEXT NOT NULL DEFAULT '1280x720',
|
||||||
"notifySubscribers" BOOLEAN NOT NULL DEFAULT true,
|
"notifySubscribers" BOOLEAN NOT NULL DEFAULT true,
|
||||||
"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,
|
||||||
"status" "JobItemStatus" NOT NULL DEFAULT 'PENDING',
|
"itemImagePath" TEXT,
|
||||||
"outputPath" TEXT,
|
"watermarkMode" TEXT NOT NULL DEFAULT 'none',
|
||||||
"youtubeVideoId" TEXT,
|
"watermarkText" TEXT,
|
||||||
"error" TEXT,
|
"watermarkLogoPath" TEXT,
|
||||||
|
"watermarkFontKey" TEXT,
|
||||||
CONSTRAINT "JobItem_pkey" PRIMARY KEY ("id")
|
"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',
|
||||||
|
"outputPath" TEXT,
|
||||||
|
"youtubeVideoId" TEXT,
|
||||||
|
"error" TEXT,
|
||||||
|
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
Reference in New Issue
Block a user