Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
async function requireApiKeyAccess() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "API keys are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const status = await getUserApiKeyStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { token, prefix } = await createUserApiKey(user.id);
|
||||
return NextResponse.json({
|
||||
apiKey: token,
|
||||
prefix,
|
||||
message: "Copy this key now. It will not be shown again.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
await revokeUserApiKey(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return NextResponse.json(
|
||||
{ error: "API rate limits are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const status = await getApiRateLimitStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWatermarkPath } from "@/lib/storage";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const buf = await fs.readFile(getWatermarkPath());
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Watermark asset not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
|
||||
import { resolveCuratedFontPath } from "@/lib/fonts-server";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> },
|
||||
) {
|
||||
const { key } = await params;
|
||||
if (!isCuratedFontKey(key)) {
|
||||
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fontPath = resolveCuratedFontPath(key);
|
||||
try {
|
||||
const buf = await fs.readFile(fontPath);
|
||||
const meta = CURATED_FONTS.find((f) => f.key === key)!;
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "font/ttf",
|
||||
"Content-Disposition": `inline; filename="${meta.file}"`,
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Font file not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
+24
-101
@@ -1,23 +1,11 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { FREE_PLAN, isAllowedResolution } from "@/lib/constants";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueVideoJob } from "@/lib/queue/client";
|
||||
import { checkQuota } from "@/lib/quota";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
import { getJobDir } from "@/lib/storage";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
|
||||
if (!metadata.title?.trim()) return "Each video must have a title";
|
||||
if (!isAllowedResolution(metadata.resolution)) return "Invalid resolution";
|
||||
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
|
||||
return "Invalid privacy setting";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
@@ -25,102 +13,29 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload;
|
||||
|
||||
if (!body.imagePath || !body.items?.length) {
|
||||
return NextResponse.json({ error: "Image and at least one audio file required" }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const item of body.items) {
|
||||
const metaError = validateItemMetadata(item.metadata);
|
||||
if (metaError) {
|
||||
return NextResponse.json({ error: metaError }, { status: 400 });
|
||||
}
|
||||
if (user.plan === "FREE" && !item.metadata.includeWatermark) {
|
||||
return NextResponse.json({ error: "Watermark is required on the free plan" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const quotaCheck = await checkQuota(user.id, body.items.length);
|
||||
if (!quotaCheck.ok) {
|
||||
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(body.imagePath);
|
||||
for (const item of body.items) {
|
||||
await fs.access(item.audioPath);
|
||||
const stat = await fs.stat(item.audioPath);
|
||||
if (stat.size > FREE_PLAN.maxFileSizeBytes) {
|
||||
return NextResponse.json({ error: `Audio file ${item.audioFilename} exceeds size limit` }, { status: 400 });
|
||||
}
|
||||
const job = await createVideoJob(user, body);
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const imageStat = await fs.stat(body.imagePath);
|
||||
if (imageStat.size > FREE_PLAN.maxFileSizeBytes) {
|
||||
return NextResponse.json({ error: "Image exceeds size limit" }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: "One or more uploaded files not found" }, { status: 400 });
|
||||
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 });
|
||||
}
|
||||
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
imagePath: body.imagePath,
|
||||
items: {
|
||||
create: body.items.map((item) => ({
|
||||
audioPath: item.audioPath,
|
||||
audioFilename: item.audioFilename,
|
||||
title: item.metadata.title.trim(),
|
||||
description: item.metadata.description || "",
|
||||
tags: item.metadata.tags || "",
|
||||
privacy: item.metadata.privacy as Privacy,
|
||||
categoryId: item.metadata.categoryId || "10",
|
||||
resolution: item.metadata.resolution,
|
||||
notifySubscribers: item.metadata.notifySubscribers,
|
||||
madeForKids: item.metadata.madeForKids,
|
||||
embeddable: item.metadata.embeddable,
|
||||
creativeCommons: item.metadata.creativeCommons,
|
||||
includeWatermark: user.plan === "FREE" ? true : item.metadata.includeWatermark,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
const jobDir = getJobDir(user.id, job.id);
|
||||
await fs.mkdir(jobDir, { recursive: true });
|
||||
|
||||
const imageExt = path.extname(body.imagePath);
|
||||
const newImagePath = path.join(jobDir, `image${imageExt}`);
|
||||
await fs.copyFile(body.imagePath, newImagePath);
|
||||
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
|
||||
|
||||
for (const item of job.items) {
|
||||
const audioExt = path.extname(item.audioPath);
|
||||
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
|
||||
await fs.copyFile(item.audioPath, newAudioPath);
|
||||
await prisma.jobItem.update({
|
||||
where: { id: item.id },
|
||||
data: { audioPath: newAudioPath },
|
||||
});
|
||||
|
||||
await enqueueVideoJob({
|
||||
jobItemId: item.id,
|
||||
userId: user.id,
|
||||
jobId: job.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ jobId: job.id });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
@@ -135,5 +50,13 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ jobs });
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
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 });
|
||||
}
|
||||
+21
-39
@@ -1,22 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { FREE_PLAN } from "@/lib/constants";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getUploadDir } from "@/lib/storage";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
const ALLOWED_AUDIO_TYPES = [
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
];
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
@@ -27,34 +14,29 @@ export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
|
||||
|
||||
if (!file || !type) {
|
||||
return NextResponse.json({ error: "Missing file or type" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > FREE_PLAN.maxFileSizeBytes) {
|
||||
if (
|
||||
!file ||
|
||||
!type ||
|
||||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: `File exceeds ${FREE_PLAN.maxFileSizeBytes / (1024 * 1024)} MB limit` },
|
||||
{ error: "Missing file or type (image | audio | logo | font)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = type === "image" ? ALLOWED_IMAGE_TYPES : ALLOWED_AUDIO_TYPES;
|
||||
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)) {
|
||||
return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
|
||||
sessionKey,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
const sessionDir = path.join(getUploadDir(), user.id, "sessions", Date.now().toString());
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const filePath = path.join(sessionDir, safeName);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return NextResponse.json({
|
||||
path: filePath,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requirePaidApiUser(_req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { audioFilename: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
privacy: true,
|
||||
categoryId: true,
|
||||
resolution: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { mapWithConcurrency } from "@/lib/fs-utils";
|
||||
import { getPlanLimits } from "@/lib/plans";
|
||||
import { checkQuota } from "@/lib/quota";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
type BatchItemInput = Partial<ItemMetadata> & {
|
||||
filename?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function defaultMetadata(title: string): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
songTitle: null,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC",
|
||||
categoryId: "10",
|
||||
resolution: "1920x1080",
|
||||
notifySubscribers: true,
|
||||
madeForKids: false,
|
||||
embeddable: true,
|
||||
creativeCommons: false,
|
||||
includeWatermark: false,
|
||||
playlistId: null,
|
||||
artist: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const image = formData.get("image") as File | null;
|
||||
const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File);
|
||||
const metadataRaw = formData.get("metadata");
|
||||
|
||||
if (!image || audioFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: image (file), audio (one or more files)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
if (audioFiles.length > limits.maxBatchSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const quotaCheck = await checkQuota(user.id, audioFiles.length);
|
||||
if (!quotaCheck.ok) {
|
||||
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
let itemMeta: BatchItemInput[] = [];
|
||||
let defaults: Partial<ItemMetadata> = {};
|
||||
let createPlaylist: CreatePlaylistRequest | null = null;
|
||||
|
||||
if (metadataRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(metadataRaw)) as {
|
||||
items?: BatchItemInput[];
|
||||
defaults?: Partial<ItemMetadata>;
|
||||
createPlaylist?: unknown;
|
||||
};
|
||||
itemMeta = parsed.items ?? [];
|
||||
defaults = parsed.defaults ?? {};
|
||||
createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist);
|
||||
if (parsed.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = Date.now().toString();
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
|
||||
|
||||
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
||||
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
|
||||
);
|
||||
|
||||
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
||||
const audio = audioFiles[i];
|
||||
const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {};
|
||||
const base = defaultMetadata(
|
||||
metaInput.title?.trim() || filenameWithoutExtension(audio.name),
|
||||
);
|
||||
const metadata: ItemMetadata = {
|
||||
...base,
|
||||
...defaults,
|
||||
...metaInput,
|
||||
title: (metaInput.title ?? defaults.title ?? base.title).trim(),
|
||||
privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy,
|
||||
};
|
||||
|
||||
return {
|
||||
audioPath: upload.path,
|
||||
audioFilename: upload.filename,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: imageUpload.path,
|
||||
items,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Batch upload failed:", err);
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Batch upload failed";
|
||||
const status =
|
||||
message.includes("Batch limit exceeded")
|
||||
? 400
|
||||
: message.includes("Quota exceeded") ||
|
||||
message.includes("Not enough credits") ||
|
||||
message.includes("Payment Required")
|
||||
? 402
|
||||
: 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||
|
||||
try {
|
||||
const createPlaylist = parseCreatePlaylistInput(body.createPlaylist);
|
||||
if (body.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(
|
||||
user,
|
||||
body.items,
|
||||
createPlaylist,
|
||||
);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: body.imagePath,
|
||||
items,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Failed to create job";
|
||||
const status =
|
||||
message.includes("Quota exceeded") ||
|
||||
message.includes("Not enough credits") ||
|
||||
message.includes("Payment Required")
|
||||
? 402
|
||||
: 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const playlists = await listYouTubePlaylists(user.id);
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (err) {
|
||||
console.error("List playlists failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Failed to list playlists";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const input = parseCreatePlaylistInput(body);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Provide JSON body: { title, description?, privacy? } where privacy is public|unlisted|private",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, input);
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err) {
|
||||
console.error("Create playlist failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Failed to create playlist";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { API_DOCS_URL } from "@/lib/plans";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
name: "Songs2VID API",
|
||||
version: "1.0",
|
||||
authentication: "Authorization: Bearer <api_key>",
|
||||
requirements: ["Pro subscription", "YouTube account connected"],
|
||||
rateLimit: "60 requests per minute per account (plus any approved bonus)",
|
||||
guidance: {
|
||||
recommended:
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||
batch:
|
||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/upload",
|
||||
description:
|
||||
"Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)",
|
||||
body: "multipart/form-data: file, type (image|audio|logo|font)",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs",
|
||||
description:
|
||||
"Create a video job from uploaded file paths. Pro: layout templates, blur, watermark, per-item covers",
|
||||
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs/batch",
|
||||
description:
|
||||
"One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs",
|
||||
body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/playlists",
|
||||
description: "List YouTube playlists for the authenticated Pro account",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/playlists",
|
||||
description: "Create a YouTube playlist",
|
||||
body: "application/json: { title, description?, privacy? (public|unlisted|private) }",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs",
|
||||
description: "List recent jobs",
|
||||
query: "limit (default 20, max 100)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs/:id",
|
||||
description: "Get job status and item details",
|
||||
},
|
||||
],
|
||||
docs: API_DOCS_URL,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
|
||||
if (
|
||||
!file ||
|
||||
!type ||
|
||||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: file, type (image|audio|logo|font)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof PremiumRequiredError) {
|
||||
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hasProFeatures } from "@/lib/edition";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
|
||||
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
|
||||
|
||||
async function requirePremiumYouTubeUser() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
|
||||
}
|
||||
if (!hasProFeatures(user.plan)) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: "Playlist features are available on the Pro plan only" },
|
||||
{ status: 403 },
|
||||
),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
if (!user.youtubeConnection) {
|
||||
return {
|
||||
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error, user } = await requirePremiumYouTubeUser();
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const playlists = await listYouTubePlaylists(user.id);
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to list playlists";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePremiumYouTubeUser();
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const input = parseCreatePlaylistInput(body);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide title, and optionally description and privacy (public|unlisted|private)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, input);
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create playlist";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { JobHistory } from "@/components/JobHistory";
|
||||
|
||||
export default async function HistoryPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/");
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">History</h1>
|
||||
<JobHistory />
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
+5
-17
@@ -1,33 +1,21 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { RecentYouTubeLimitAlert } from "@/components/RecentYouTubeLimitAlert";
|
||||
import { UploadForm } from "@/components/UploadForm";
|
||||
import { SignOutButton } from "@/components/SignOutButton";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) redirect("/");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<a href="/" className="text-xl font-bold text-white">
|
||||
s2yt
|
||||
</a>
|
||||
{session.user.channelTitle && (
|
||||
<p className="text-xs text-gray-500">Channel: {session.user.channelTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Create Videos</h1>
|
||||
<RecentYouTubeLimitAlert />
|
||||
<UploadForm />
|
||||
</div>
|
||||
</main>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AccountPrivacyActions } from "@/components/AccountPrivacyActions";
|
||||
import { CreditPurchasePanel } from "@/components/CreditPurchasePanel";
|
||||
import { PlanBillingActions } from "@/components/PlanBillingActions";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { UpgradeProButton } from "@/components/UpgradeProButton";
|
||||
import { getUserApiKeyStatus } from "@/lib/api-keys";
|
||||
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
|
||||
import { ApiKeySettings } from "@/components/ApiKeySettings";
|
||||
import { hasProFeatures, isSelfHostedEdition } from "@/lib/edition";
|
||||
import { EXTENSION_KIND, getQuotaExtensionUsage } from "@/lib/quota-extensions";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
import { isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||||
import { CreditPurchaseStatus } from "@prisma/client";
|
||||
|
||||
const PLAN_LABELS = {
|
||||
FREE: "Bedroom Producer",
|
||||
PREMIUM: "Pro",
|
||||
} as const;
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-gray-400">{label}</dt>
|
||||
<dd className="text-right text-white">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) redirect("/");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
include: { youtubeConnection: true },
|
||||
});
|
||||
if (!user) redirect("/");
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
const selfHosted = isSelfHostedEdition();
|
||||
const proFeatures = hasProFeatures(user.plan);
|
||||
const recentCreditPurchases = await prisma.creditPurchase.findMany({
|
||||
where: { userId: user.id, status: CreditPurchaseStatus.COMPLETED },
|
||||
orderBy: { completedAt: "desc" },
|
||||
take: 5,
|
||||
});
|
||||
const extensionUsage =
|
||||
!selfHosted && user.plan === "PREMIUM" ? await getQuotaExtensionUsage(user.id) : null;
|
||||
const apiRateExtensionUsage = proFeatures
|
||||
? await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT)
|
||||
: null;
|
||||
const apiKeyStatus = proFeatures ? await getUserApiKeyStatus(user.id) : null;
|
||||
const apiRateLimit = proFeatures ? await getApiRateLimitStatus(user.id) : null;
|
||||
const planLabel = selfHosted ? "Self-hosted" : PLAN_LABELS[user.plan];
|
||||
const youtube = user.youtubeConnection;
|
||||
const channelUrl = youtube ? `https://www.youtube.com/channel/${youtube.channelId}` : null;
|
||||
|
||||
return (
|
||||
<DashboardShell channelTitle={youtube?.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">Settings</h1>
|
||||
|
||||
<div className="space-y-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>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Name" value={user.name || "Not set"} />
|
||||
<InfoRow label="Email" value={user.email} />
|
||||
</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 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>
|
||||
|
||||
<div className="mb-6 rounded border border-gray-800 bg-surface p-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Video quota remaining
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-white">
|
||||
{quota.totalAvailable}
|
||||
<span className="text-base font-normal text-gray-400">
|
||||
{" "}
|
||||
of {quota.limit + quota.extraCredits} videos
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{quota.used} monthly used
|
||||
{quota.extraCredits > 0 ? ` · ${quota.extraCredits} extras` : ""}
|
||||
{quota.bonusQuota > 0 ? ` · +${quota.bonusQuota} bonus` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded bg-gray-800">
|
||||
<div
|
||||
className={`h-full rounded ${
|
||||
quota.totalAvailable <= 0
|
||||
? "bg-red-500"
|
||||
: quota.totalAvailable / Math.max(1, quota.limit + quota.extraCredits) <= 0.2
|
||||
? "bg-amber-500"
|
||||
: "bg-accent"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((quota.limit + quota.extraCredits - quota.totalAvailable) /
|
||||
Math.max(1, quota.limit + quota.extraCredits)) *
|
||||
100,
|
||||
),
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
{`Includes monthly + purchased extras · monthly resets on ${quota.resetsIn}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-3 text-sm">
|
||||
<InfoRow label="Current plan" value={planLabel} />
|
||||
<InfoRow
|
||||
label="Monthly credits"
|
||||
value={`${quota.monthlyCredits} / cycle`}
|
||||
/>
|
||||
<InfoRow label="Extra credits" value={`${quota.extraCredits}`} />
|
||||
{user.plan === "PREMIUM" && user.subscribedAt && (
|
||||
<InfoRow
|
||||
label="Subscribed since"
|
||||
value={user.subscribedAt.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{user.plan === "PREMIUM" && (
|
||||
<InfoRow
|
||||
label="Payment method"
|
||||
value={user.cardLast4 ? `•••• ${user.cardLast4}` : "No card on file"}
|
||||
/>
|
||||
)}
|
||||
{extensionUsage && (
|
||||
<InfoRow
|
||||
label="Extension requests (this year)"
|
||||
value={`${extensionUsage.used} / ${extensionUsage.limit}`}
|
||||
/>
|
||||
)}
|
||||
<InfoRow label="Max batch size" value={`${quota.maxBatchSize} videos`} />
|
||||
<InfoRow label="Max resolution" value={`${quota.maxResolutionHeight}p`} />
|
||||
</dl>
|
||||
|
||||
{user.plan === "FREE" ? (
|
||||
<>
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
<UpgradeProButton /> for 50 videos/month, 1080p, API access, and lossless audio.
|
||||
</p>
|
||||
{!selfHosted && (
|
||||
<CreditPurchasePanel
|
||||
initialCredits={user.extraCredits}
|
||||
stripeConfigured={isStripeConfigured() || isBillingDevMock()}
|
||||
recentPurchases={recentCreditPurchases.map((p) => ({
|
||||
id: p.id,
|
||||
credits: p.credits,
|
||||
amountCents: p.amountCents,
|
||||
completedAt: p.completedAt?.toISOString() ?? null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{extensionUsage && <PlanBillingActions initialUsage={extensionUsage} />}
|
||||
<p className="mt-6 border-t border-gray-800 pt-6 text-sm text-gray-400">
|
||||
Pro uses your monthly allocation first, then any never-expiring extra credits.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{proFeatures && apiKeyStatus && apiRateLimit && apiRateExtensionUsage && (
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">API access</h2>
|
||||
<ApiKeySettings
|
||||
initialStatus={apiKeyStatus}
|
||||
initialRateLimit={apiRateLimit}
|
||||
initialExtensionUsage={apiRateExtensionUsage}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-700 bg-surface-light p-6">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">Account deletion & data</h2>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Request a copy of your data or permanently delete your account and associated uploads.
|
||||
</p>
|
||||
<AccountPrivacyActions email={user.email} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
+233
@@ -3,6 +3,10 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface-dark text-gray-100 antialiased;
|
||||
}
|
||||
@@ -12,4 +16,233 @@
|
||||
.input-field {
|
||||
@apply w-full rounded border border-gray-600 bg-surface-light px-3 py-2 text-sm text-white placeholder-gray-500 focus:border-accent focus:outline-none;
|
||||
}
|
||||
|
||||
.mockup-upload-box {
|
||||
animation: mockup-border-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-upload-box-image {
|
||||
animation: mockup-border-pulse 3s ease-in-out infinite,
|
||||
mockup-float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-upload-box-audio {
|
||||
animation: mockup-border-pulse 3s ease-in-out infinite 0.5s,
|
||||
mockup-float 4s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.mockup-icon-image {
|
||||
animation: mockup-icon-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-icon-audio {
|
||||
animation: mockup-icon-glow 3s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.mockup-progress-shimmer {
|
||||
animation: mockup-shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-dot-1 {
|
||||
animation: processing-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mockup-dot-2 {
|
||||
animation: processing-dot 1.4s ease-in-out infinite 0.2s;
|
||||
}
|
||||
|
||||
.mockup-dot-3 {
|
||||
animation: processing-dot 1.4s ease-in-out infinite 0.4s;
|
||||
}
|
||||
|
||||
.benefits-flow-line {
|
||||
animation: benefits-flow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.benefits-process-icon {
|
||||
animation: benefits-process-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.benefits-flow-path {
|
||||
stroke-dasharray: 6 8;
|
||||
animation: benefits-flow-dash 2s linear infinite;
|
||||
}
|
||||
|
||||
.mobile-sidebar-panel-open {
|
||||
animation: mobile-sidebar-open 0.42s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-panel-close {
|
||||
animation: mobile-sidebar-close 0.3s cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-close-btn-open {
|
||||
animation: mobile-sidebar-close-btn-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-close-btn-close {
|
||||
animation: mobile-sidebar-close-btn-out 0.2s ease-in forwards;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link {
|
||||
@apply relative block overflow-hidden rounded-lg px-4 py-3 text-base font-medium text-gray-300 transition-all duration-300 ease-out;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link::before {
|
||||
content: "";
|
||||
@apply absolute bottom-2 left-0 top-2 w-1 origin-left scale-y-0 rounded-r bg-accent transition-transform duration-300 ease-out;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link:hover {
|
||||
@apply translate-x-1 bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.12)];
|
||||
}
|
||||
|
||||
.mobile-sidebar-link:hover::before {
|
||||
@apply scale-y-100;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-active {
|
||||
@apply bg-surface-light text-white shadow-[inset_0_0_0_1px_rgba(74,158,255,0.2)];
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-active::before {
|
||||
@apply scale-y-100;
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-danger:hover {
|
||||
@apply bg-red-500/10 text-red-300 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.2)];
|
||||
}
|
||||
|
||||
.mobile-sidebar-link-danger:hover::before {
|
||||
@apply bg-red-400;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-border-pulse {
|
||||
0%,
|
||||
100% {
|
||||
border-color: rgb(75 85 99 / 0.5);
|
||||
box-shadow: 0 0 0 0 rgb(255 255 255 / 0);
|
||||
}
|
||||
50% {
|
||||
border-color: rgb(156 163 175 / 0.7);
|
||||
box-shadow: 0 0 12px 0 rgb(255 255 255 / 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-icon-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mockup-shimmer {
|
||||
0% {
|
||||
left: -30%;
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes processing-dot {
|
||||
0%,
|
||||
20% {
|
||||
opacity: 0;
|
||||
}
|
||||
40%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes benefits-flow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes benefits-process-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 28px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes benefits-flow-dash {
|
||||
to {
|
||||
stroke-dashoffset: -28;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-open {
|
||||
from {
|
||||
opacity: 0.85;
|
||||
transform: translateX(100%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0.85;
|
||||
transform: translateX(100%) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-close-btn-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) rotate(-90deg);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-sidebar-close-btn-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
+3
-10
@@ -1,6 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { DashboardShell } from "@/components/DashboardShell";
|
||||
import { JobProgress } from "@/components/JobProgress";
|
||||
|
||||
type Props = {
|
||||
@@ -14,18 +15,10 @@ export default async function JobPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
|
||||
<a href="/dashboard" className="text-xl font-bold text-white">
|
||||
s2yt
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DashboardShell channelTitle={session.user.channelTitle}>
|
||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
||||
<JobProgress jobId={id} />
|
||||
</div>
|
||||
</main>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-4
@@ -6,8 +6,9 @@ import { Providers } from "./providers";
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "s2yt — Image + Audio to YouTube",
|
||||
description: "Create and upload YouTube videos from an image and audio files",
|
||||
title: "Songs2VID",
|
||||
description:
|
||||
"Songs2VID is an automation tool that converts your audio and image files into high-quality videos and uploads them directly to YouTube.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -16,8 +17,8 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className} suppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+78
-53
@@ -1,68 +1,93 @@
|
||||
import Link from "next/link";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { BenefitsSection } from "@/components/BenefitsSection";
|
||||
import { DownloadSection } from "@/components/DownloadSection";
|
||||
import { LandingNavbar } from "@/components/LandingNavbar";
|
||||
import { SignInButton } from "@/components/SignInButton";
|
||||
import { PricingSection } from "@/components/PricingSection";
|
||||
import { StepsSection } from "@/components/StepsSection";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { SupportSection } from "@/components/SupportSection";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<header className="border-b border-gray-800 bg-surface">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<span className="text-xl font-bold text-white">s2yt</span>
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<SignInButton />
|
||||
)}
|
||||
<section className="relative min-h-dvh overflow-hidden">
|
||||
<video
|
||||
autoPlay
|
||||
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">
|
||||
Songs2VID is an automation tool that converts your audio and image files into
|
||||
high-quality videos and uploads them directly to YouTube.
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<section className="mx-auto max-w-3xl px-6 py-20 text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold text-white">
|
||||
Turn images and audio into YouTube videos
|
||||
</h1>
|
||||
<p className="mb-8 text-lg text-gray-400">
|
||||
Upload one image and one or more audio files. Each audio becomes its own video
|
||||
with individual metadata, then uploads directly to your YouTube channel.
|
||||
</p>
|
||||
|
||||
<div className="mb-12 grid gap-4 text-left sm:grid-cols-3">
|
||||
<Feature title="Batch creation" desc="One image, many audios — each becomes a separate video." />
|
||||
<Feature title="Per-video settings" desc="Title, tags, privacy, and category for every upload." />
|
||||
<Feature title="Direct upload" desc="Connect YouTube once and publish automatically." />
|
||||
</div>
|
||||
|
||||
{session ? (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-block rounded bg-accent px-8 py-3 font-medium text-white hover:bg-accent-hover"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<SignInButton large />
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-sm text-gray-500">
|
||||
Free plan: up to 14 videos/month, 720p max, 30 MB per file
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<StepsSection />
|
||||
<BenefitsSection />
|
||||
|
||||
<DownloadSection />
|
||||
|
||||
<PricingSection />
|
||||
|
||||
<SupportSection />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Feature({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-700 bg-surface p-4">
|
||||
<h3 className="mb-1 font-medium text-white">{title}</h3>
|
||||
<p className="text-sm text-gray-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+136
-37
@@ -42,36 +42,60 @@ export default function PrivacyPage() {
|
||||
<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 on your behalf</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</h3>
|
||||
<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</li>
|
||||
<li>Generated video files</li>
|
||||
<li>Per-video metadata you provide (title, description, tags, privacy settings, etc.)</li>
|
||||
<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 and technical data</h3>
|
||||
<h3>3.3 Usage, API, and technical data</h3>
|
||||
<ul>
|
||||
<li>Plan type, quota usage, and job processing status</li>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<h3>3.4 Payment data</h3>
|
||||
<p>
|
||||
If you purchase a paid plan, payment processing is handled by our payment provider. We do
|
||||
not store full payment card details on our servers. We may receive billing status,
|
||||
subscription identifiers, and transaction references.
|
||||
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, and
|
||||
YouTube upload features you request
|
||||
<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
|
||||
@@ -91,14 +115,35 @@ export default function PrivacyPage() {
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google / YouTube:</strong> authentication and video uploads via Google OAuth and
|
||||
the YouTube Data API
|
||||
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>Payment processors:</strong> for Pro and Enterprise billing when available
|
||||
<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>
|
||||
</ul>
|
||||
<p>
|
||||
@@ -106,26 +151,71 @@ export default function PrivacyPage() {
|
||||
appropriate contractual safeguards where required.
|
||||
</p>
|
||||
|
||||
<h2>Google User Data Sharing and Disclosure</h2>
|
||||
<h2>6. Google / YouTube user data (Limited Use)</h2>
|
||||
<p>
|
||||
We do not sell, share, transfer, or disclose any Google user data to any third parties.
|
||||
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>
|
||||
All user data retrieved via Google OAuth APIs is used solely and strictly for the core
|
||||
functionality of the application (uploading user-generated media) and is never distributed,
|
||||
transferred, or disclosed to external services, partners, or third parties.
|
||||
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>6. Data retention</h2>
|
||||
<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</li>
|
||||
<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 request deletion of your account data subject to legal retention obligations.</p>
|
||||
<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>7. Self-hosted deployments</h2>
|
||||
<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
|
||||
@@ -133,7 +223,7 @@ export default function PrivacyPage() {
|
||||
hosting for you under contract.
|
||||
</p>
|
||||
|
||||
<h2>8. Your rights</h2>
|
||||
<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>
|
||||
@@ -141,48 +231,57 @@ export default function PrivacyPage() {
|
||||
<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</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>.
|
||||
<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>9. Cookies and local storage</h2>
|
||||
<h2>10. Cookies and local storage</h2>
|
||||
<p>
|
||||
We use essential cookies and similar technologies for authentication, session management,
|
||||
and security. We do not use non-essential tracking cookies unless disclosed separately and
|
||||
enabled with your consent where required.
|
||||
</p>
|
||||
|
||||
<h2>10. Security</h2>
|
||||
<h2>11. Security</h2>
|
||||
<p>
|
||||
We implement appropriate technical and organizational measures to protect your data,
|
||||
including encryption in transit, access controls, and isolated processing environments.
|
||||
No method of transmission or storage is 100% secure.
|
||||
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>11. International transfers</h2>
|
||||
<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.
|
||||
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>12. Children</h2>
|
||||
<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>13. Changes to this policy</h2>
|
||||
<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.
|
||||
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>14. Contact</h2>
|
||||
<h2>15. Contact</h2>
|
||||
<p>
|
||||
Questions about this Privacy Policy:{" "}
|
||||
Questions about this Privacy Policy or our privacy practices:{" "}
|
||||
<a href={`mailto:${LEGAL_OPERATOR.email}`}>{LEGAL_OPERATOR.email}</a>
|
||||
</p>
|
||||
</LegalPageLayout>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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 | Songs2VID",
|
||||
description: "Refund and cancellation policy for Songs2VID paid plans.",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LegalPageLayout } from "@/components/LegalPageLayout";
|
||||
import { LEGAL_OPERATOR } from "@/lib/legal/constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service | Songs2VID",
|
||||
description: "Terms and conditions for using the Songs2VID hosted service.",
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Terms of Service"
|
||||
description="Please read these terms carefully before using Songs2VID."
|
||||
>
|
||||
<h2>1. Agreement</h2>
|
||||
<p>
|
||||
These Terms of Service ("Terms") govern your access to and use of the Songs2VID
|
||||
website and hosted cloud service (the "Service") operated by{" "}
|
||||
{LEGAL_OPERATOR.legalName} ("we", "us"). By creating an account or using
|
||||
the Service, you agree to these Terms.
|
||||
</p>
|
||||
<p>
|
||||
If you do not agree, do not use the Service. If you self-host the open-source software on
|
||||
your own infrastructure without using our hosted Service, these Terms apply only to the
|
||||
extent you use our website, support channels, or paid services we provide.
|
||||
</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>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user