feat: export waitlist

This commit is contained in:
Maze Winther 2025-08-17 18:50:13 +02:00
parent bb461fa9fb
commit f3ddf26334
8 changed files with 681 additions and 59 deletions

View File

@ -0,0 +1,10 @@
CREATE TABLE "export_waitlist" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "export_waitlist_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "export_waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "waitlist" CASCADE;

View File

@ -0,0 +1,365 @@
{
"id": "71b84015-78f8-4d07-9115-8d56ea550459",
"prevId": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.export_waitlist": {
"name": "export_waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"export_waitlist_email_unique": {
"name": "export_waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -22,6 +22,13 @@
"when": 1752569400809,
"tag": "0002_cuddly_pretty_boy",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1755377469662,
"tag": "0003_long_polaris",
"breakpoints": true
}
]
}
}

View File

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { baseRateLimit } from "@/lib/rate-limit";
import { db, exportWaitlist, eq } from "@opencut/db";
import { randomUUID } from "crypto";
import {
exportWaitlistSchema,
exportWaitlistResponseSchema,
} from "@/lib/schemas/waitlist";
const requestSchema = exportWaitlistSchema;
const responseSchema = exportWaitlistResponseSchema;
export async function POST(request: NextRequest) {
try {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}
const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const { email } = parsed.data;
const existing = await db
.select({ id: exportWaitlist.id })
.from(exportWaitlist)
.where(eq(exportWaitlist.email, email))
.limit(1);
if (existing.length > 0) {
const responseData = { success: true, alreadySubscribed: true } as const;
const validated = responseSchema.safeParse(responseData);
if (!validated.success) {
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 }
);
}
return NextResponse.json(validated.data);
}
await db.insert(exportWaitlist).values({
id: randomUUID(),
email,
createdAt: new Date(),
updatedAt: new Date(),
});
const responseData = { success: true } as const;
const validated = responseSchema.safeParse(responseData);
if (!validated.success) {
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 }
);
}
return NextResponse.json(validated.data);
} catch (error) {
console.error("Waitlist API error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -17,6 +17,10 @@ const protectedRoutes = [
path: "/none",
method: "GET",
},
{
path: "/api/waitlist/export",
method: "POST",
},
];
export default function RootLayout({

View File

@ -1,58 +1,185 @@
"use client";
import { useState } from "react";
import { TransitionUpIcon } from "./icons";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
export function ExportButton() {
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const handleExport = () => {
setIsExportDialogOpen(true);
};
return (
<>
<button
className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] cursor-pointer hover:brightness-95 transition-all duration-200"
onClick={handleExport}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export (soon)</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
</div>
</div>
</button>
<ExportDialog
isOpen={isExportDialogOpen}
onOpenChange={setIsExportDialogOpen}
/>
</>
);
}
function ExportDialog({
isOpen,
onOpenChange,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="p-6">
<DialogHeader>
<DialogTitle>Export Project</DialogTitle>
</DialogHeader>
<div>
<p className="text-muted-foreground">
Export functionality is not ready yet. We're currently working on a
custom pipeline to make this possible.
</p>
</div>
</DialogContent>
</Dialog>
);
}
"use client";
import { useState } from "react";
import { TransitionUpIcon } from "./icons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { useForm } from "react-hook-form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "./ui/form";
import {
exportWaitlistSchema,
type ExportWaitlistInput,
type ExportWaitlistResponse,
} from "@/lib/schemas/waitlist";
import { cn } from "@/lib/utils";
export function ExportButton() {
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const handleExport = () => {
setIsExportDialogOpen(true);
};
return (
<>
<button
type="button"
className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] cursor-pointer hover:brightness-95 transition-all duration-200"
onClick={handleExport}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleExport();
}
}}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export (soon)</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div>
</div>
</div>
</button>
<ExportDialog
isOpen={isExportDialogOpen}
onOpenChange={setIsExportDialogOpen}
/>
</>
);
}
function ExportDialog({
isOpen,
onOpenChange,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
const form = useForm<ExportWaitlistInput>({ defaultValues: { email: "" } });
const { isSubmitting } = form.formState;
const [serverMessage, setServerMessage] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
const onSubmit = form.handleSubmit(async (values) => {
const parsed = exportWaitlistSchema.safeParse(values);
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const fieldName =
(issue.path[0] as keyof ExportWaitlistInput) || "email";
form.setError(fieldName, { type: "zod", message: issue.message });
}
return;
}
setServerMessage(null);
const { email } = parsed.data;
const response = await fetch("/api/waitlist/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!response.ok) {
setServerMessage({
text: "Something went wrong. Please try again.",
type: "error",
});
return;
}
const data: ExportWaitlistResponse = await response.json();
if (data.success && data.alreadySubscribed) {
setServerMessage({
text: "You're already on the list.",
type: "success",
});
return;
}
if (data.success) {
setServerMessage({
text: "You're on the list. We'll email you when it's ready.",
type: "success",
});
form.reset();
return;
}
setServerMessage({
text: "Couldn't add your email. Please try again.",
type: "error",
});
});
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="p-6">
<DialogHeader>
<DialogTitle>Export Project</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<DialogDescription>
Export isn't ready yet. we're building a custom pipeline to make it
great.
</DialogDescription>
<Form {...form}>
<form className="flex flex-col gap-5" onSubmit={onSubmit}>
<div className="flex flex-col gap-4 w-full">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@example.com"
inputMode="email"
autoComplete="email"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription>
We'll let you know once export is ready.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Notify me"}
</Button>
</div>
{serverMessage ? (
<p
role="status"
aria-live="polite"
className={cn(
"text-xs",
serverMessage.type === "success" ? "text-green-600" : "text-red-600"
)}
>
{serverMessage.text}
</p>
) : null}
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,15 @@
import { z } from "zod";
export const exportWaitlistSchema = z.object({
email: z.string().email().max(320),
});
export const exportWaitlistResponseSchema = z.object({
success: z.boolean(),
alreadySubscribed: z.boolean().optional(),
});
export type ExportWaitlistInput = z.infer<typeof exportWaitlistSchema>;
export type ExportWaitlistResponse = z.infer<
typeof exportWaitlistResponseSchema
>;

View File

@ -57,3 +57,14 @@ export const verifications = pgTable("verifications", {
() => /* @__PURE__ */ new Date()
),
}).enableRLS();
export const exportWaitlist = pgTable("export_waitlist", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp("updated_at")
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
}).enableRLS();