From 3d8e86d9e4bf3328bcc26f9c3a9da71bd0c1c79b Mon Sep 17 00:00:00 2001 From: Yash Dewasthale Date: Mon, 21 Sep 2026 13:24:15 +0530 Subject: [PATCH] feat: implement Nova early invite feature - Added NovaInviteForm component for user input to request early access. - Created API route for handling early invite submissions and sending confirmation emails. - Introduced NovaEarlyInvite model in the database schema to store invite details. - Updated package.json scripts to include database generation commands. - Enhanced error handling in the invite submission process. --- .../web/app/(pages)/nova/nova-invite-form.tsx | 175 ++++++++++++++++++ apps/web/app/(pages)/nova/page.tsx | 159 ++++++++++++++++ apps/web/app/api/nova/early-invite/route.ts | 76 ++++++++ .../animate-ui/icons/clipboard-list.tsx | 161 ++++++++++++++++ .../animate-ui/icons/send-horizontal.tsx | 85 +++++++++ apps/web/modules/email/nova-early-invite.ts | 89 +++++++++ apps/web/package.json | 2 +- packages/db/package.json | 2 +- .../migration.sql | 20 ++ packages/db/prisma/schema.prisma | 15 ++ 10 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(pages)/nova/nova-invite-form.tsx create mode 100644 apps/web/app/(pages)/nova/page.tsx create mode 100644 apps/web/app/api/nova/early-invite/route.ts create mode 100644 apps/web/components/animate-ui/icons/clipboard-list.tsx create mode 100644 apps/web/components/animate-ui/icons/send-horizontal.tsx create mode 100644 apps/web/modules/email/nova-early-invite.ts create mode 100644 packages/db/prisma/migrations/20260921070000_nova_early_invite/migration.sql diff --git a/apps/web/app/(pages)/nova/nova-invite-form.tsx b/apps/web/app/(pages)/nova/nova-invite-form.tsx new file mode 100644 index 0000000..405d62e --- /dev/null +++ b/apps/web/app/(pages)/nova/nova-invite-form.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState, useTransition, type FormEvent } from "react" +import { Check, LoaderCircle } from "lucide-react" + +import { SendHorizontalIcon } from "@/components/animate-ui/icons/send-horizontal" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +type FormStatus = + | { type: "idle"; message: "" } + | { type: "success" | "error"; message: string } + +export function NovaInviteForm() { + const [isPending, startTransition] = useTransition() + const [status, setStatus] = useState({ type: "idle", message: "" }) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + const form = event.currentTarget + const formData = new FormData(form) + + startTransition(async () => { + setStatus({ type: "idle", message: "" }) + + try { + const response = await fetch("/api/nova/early-invite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.get("name"), + role: formData.get("role"), + email: formData.get("email"), + website: formData.get("website"), + }), + }) + const result = (await response.json()) as { + message?: string + error?: string + } + + if (!response.ok) { + setStatus({ + type: "error", + message: result.error || "Something went wrong. Please try again.", + }) + return + } + + form.reset() + setStatus({ + type: "success", + message: result.message || "You’re on Nova’s early invite list.", + }) + } catch { + setStatus({ + type: "error", + message: "We couldn’t reach Nova. Please try again.", + }) + } + }) + } + + const fieldClassName = + "h-11 rounded-lg border-white/[0.09] bg-black/20 px-3.5 text-sm shadow-inner shadow-black/10 transition-[border-color,background-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] placeholder:text-muted-foreground/30 focus-visible:border-primary/45 focus-visible:bg-black/30 focus-visible:ring-2 focus-visible:ring-primary/10 disabled:opacity-50" + + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ +
+ {status.type === "success" ? ( +

+ + {status.message} +

+ ) : null} + {status.type === "error" ? ( +

+ {status.message} +

+ ) : null} +
+ +

+

+
+ ) +} diff --git a/apps/web/app/(pages)/nova/page.tsx b/apps/web/app/(pages)/nova/page.tsx new file mode 100644 index 0000000..82ac0c9 --- /dev/null +++ b/apps/web/app/(pages)/nova/page.tsx @@ -0,0 +1,159 @@ +import type { Metadata } from "next" +import { Bot, Check, CodeXml, MessagesSquare, Mic2 } from "lucide-react" + +import { ClipboardListIcon } from "@/components/animate-ui/icons/clipboard-list" +import Footer from "@/components/homepage/footer" +import Navbar from "@/components/homepage/navbar" +import { NovaInviteForm } from "./nova-invite-form" + +export const metadata: Metadata = { + title: "Nova — The AI Engineer | Supercode", + description: + "Meet Nova by Supercode: an AI engineer that owns tasks, works through an agent harness, reviews code, and collaborates with your team by voice.", + openGraph: { + title: "Nova — The AI Engineer", + description: + "An accountable AI engineering teammate for implementation, code review, communication, and voice collaboration.", + url: "/nova", + }, +} + +const capabilities = [ + { + icon: Bot, + label: "Agent harness", + detail: "Plans, executes, tests, and reports instead of stopping at suggestions.", + }, + { + icon: CodeXml, + label: "Code review", + detail: "Understands the codebase, surfaces risk, and helps move pull requests forward.", + }, + { + icon: Mic2, + label: "Voice native", + detail: "Discuss work, make decisions, and steer tasks as naturally as a teammate.", + }, + { + icon: MessagesSquare, + label: "Team connected", + detail: "Built to work where engineering teams already coordinate and communicate.", + }, +] + +export default function NovaPage() { + return ( +
+
+ ) +} diff --git a/apps/web/app/api/nova/early-invite/route.ts b/apps/web/app/api/nova/early-invite/route.ts new file mode 100644 index 0000000..ce26e71 --- /dev/null +++ b/apps/web/app/api/nova/early-invite/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" +import prisma from "@super/db" +import { z } from "zod" + +import { sendNovaEarlyInviteConfirmation } from "@/modules/email/nova-early-invite" + +const novaInviteSchema = z.object({ + name: z.string().trim().min(2).max(100), + role: z.string().trim().min(2).max(120), + email: z.string().trim().toLowerCase().email().max(254), + website: z.string().max(200).optional(), +}) + +export async function POST(request: Request) { + try { + const payload = await request.json() + const parsed = novaInviteSchema.safeParse(payload) + + if (!parsed.success) { + return NextResponse.json( + { error: "Please enter a valid name, role, and work email." }, + { status: 400 }, + ) + } + + const { name, role, email, website } = parsed.data + if (website) { + return NextResponse.json({ message: "You’re on Nova’s early invite list." }) + } + + const existing = await prisma.novaEarlyInvite.findUnique({ + where: { email }, + select: { id: true, emailStatus: true }, + }) + + if (existing) { + return NextResponse.json({ + message: "You’re already on Nova’s early invite list.", + }) + } + + const invite = await prisma.novaEarlyInvite.create({ + data: { name, role, email }, + select: { id: true }, + }) + + const delivery = await sendNovaEarlyInviteConfirmation({ + inviteId: invite.id, + name, + email, + }) + + await prisma.novaEarlyInvite.update({ + where: { id: invite.id }, + data: delivery.sent + ? { + emailStatus: "sent", + resendEmailId: delivery.emailId, + emailSentAt: new Date(), + } + : { emailStatus: "failed" }, + }) + + return NextResponse.json({ + message: delivery.sent + ? "You’re in. Check your inbox for Nova’s confirmation." + : "You’re on Nova’s early invite list. We’ll be in touch.", + }) + } catch (error) { + console.error("[nova-early-invite] signup failed:", error) + return NextResponse.json( + { error: "We couldn’t add you right now. Please try again." }, + { status: 500 }, + ) + } +} diff --git a/apps/web/components/animate-ui/icons/clipboard-list.tsx b/apps/web/components/animate-ui/icons/clipboard-list.tsx new file mode 100644 index 0000000..acc7201 --- /dev/null +++ b/apps/web/components/animate-ui/icons/clipboard-list.tsx @@ -0,0 +1,161 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type ClipboardListProps = IconProps; + +const animations = { + default: { + rect: {}, + path1: {}, + path2: { + initial: { + pathLength: 1, + opacity: 1, + scale: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + scale: [1.1, 1], + transition: { + duration: 0.4, + ease: 'easeInOut', + }, + }, + }, + path3: { + initial: { + pathLength: 1, + opacity: 1, + scale: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + scale: [1.1, 1], + transition: { + duration: 0.4, + ease: 'easeInOut', + delay: 0.2, + }, + }, + }, + path4: { + initial: { + pathLength: 1, + opacity: 1, + scale: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + scale: [1.1, 1], + transition: { + duration: 0.4, + ease: 'easeInOut', + delay: 0.5, + }, + }, + }, + path5: { + initial: { + pathLength: 1, + opacity: 1, + scale: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + scale: [1.1, 1], + transition: { + duration: 0.4, + ease: 'easeInOut', + delay: 0.7, + }, + }, + }, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: ClipboardListProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + + + + + + ); +} + +function ClipboardList(props: ClipboardListProps) { + return ; +} + +export { + animations, + ClipboardList, + ClipboardList as ClipboardListIcon, + type ClipboardListProps, + type ClipboardListProps as ClipboardListIconProps, +}; diff --git a/apps/web/components/animate-ui/icons/send-horizontal.tsx b/apps/web/components/animate-ui/icons/send-horizontal.tsx new file mode 100644 index 0000000..e3e69ee --- /dev/null +++ b/apps/web/components/animate-ui/icons/send-horizontal.tsx @@ -0,0 +1,85 @@ +'use client'; + +import * as React from 'react'; +import { motion, type Variants } from 'motion/react'; + +import { + getVariants, + useAnimateIconContext, + IconWrapper, + type IconProps, +} from '@/components/animate-ui/icons/icon'; + +type SendHorizontalProps = IconProps; + +const animations = { + default: { + group: { + initial: { + scale: 1, + x: 0, + }, + animate: { + scale: [1, 0.8, 1, 1, 1], + x: [0, '-10%', '125%', '-150%', 0], + transition: { + default: { ease: 'easeInOut', duration: 1.2 }, + x: { + ease: 'easeInOut', + duration: 1.2, + times: [0, 0.25, 0.5, 0.5, 1], + }, + }, + }, + }, + path1: {}, + path2: {}, + } satisfies Record, +} as const; + +function IconComponent({ size, ...props }: SendHorizontalProps) { + const { controls } = useAnimateIconContext(); + const variants = getVariants(animations); + + return ( + + + + + + + ); +} + +function SendHorizontal(props: SendHorizontalProps) { + return ; +} + +export { + animations, + SendHorizontal, + SendHorizontal as SendHorizontalIcon, + type SendHorizontalProps, + type SendHorizontalProps as SendHorizontalIconProps, +}; diff --git a/apps/web/modules/email/nova-early-invite.ts b/apps/web/modules/email/nova-early-invite.ts new file mode 100644 index 0000000..575edd9 --- /dev/null +++ b/apps/web/modules/email/nova-early-invite.ts @@ -0,0 +1,89 @@ +import { getResend, getResendFromAddress } from "./resend" + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + +export type NovaInviteEmailResult = + | { sent: true; emailId: string | null } + | { sent: false; reason: string } + +export async function sendNovaEarlyInviteConfirmation(input: { + inviteId: string + name: string + email: string +}): Promise { + const resend = getResend() + if (!resend) { + return { sent: false, reason: "resend_not_configured" } + } + + const firstName = input.name.trim().split(/\s+/)[0] || "there" + const novaUrl = "https://x.com/dewyashtwts/status/2101545152485691465?s=20" + const text = `Hey ${firstName}, Yash from Supercode here. + +Thanks for signing up for Nova. + +We’ve been hacking on an AI engineer that can take a task, work through the code, run the checks, and report back like a teammate. + +It’s still early. Not a polished launch. We mostly want to build Nova alongside a small group of engineering teams and learn what is actually useful. + +You’re on that list. + +We’ll email you as soon as your access is ready. In the meantime, you can take another look here: +${novaUrl} + +Talk soon! + +- Yash` + + const { data, error } = await resend.emails.send( + { + from: getResendFromAddress(), + to: [input.email], + subject: "You’re on the Nova list", + text, + html: ` + + + +
A quick note from Yash about Nova.
+ + + + +
+ + + + +
+

Hey ${escapeHtml(firstName)}, Yash from Supercode here.

+

Thanks for signing up for Nova.

+

We’ve been hacking on an AI engineer that can take a task, work through the code, run the checks, and report back like a teammate.

+

It’s still early. Not a polished launch. We mostly want to build Nova alongside a small group of engineering teams and learn what is actually useful.

+

You’re on that list.

+

We’ll email you as soon as your access is ready. In the meantime, you can take another look at Nova here.

+

Talk soon!

+

- Yash

+
+
+ +`, + tags: [{ name: "category", value: "nova_early_invite" }], + }, + { idempotencyKey: `nova-early-invite/${input.inviteId}` }, + ) + + if (error) { + console.error("[nova-early-invite] Resend delivery failed:", error) + return { sent: false, reason: error.message || "resend_error" } + } + + return { sent: true, emailId: data?.id ?? null } +} diff --git a/apps/web/package.json b/apps/web/package.json index 7827a3b..e0ccb60 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "bun run db:generate && next dev", "build": "bun run db:generate && next build --webpack", "postinstall": "bun run db:generate", "start": "next start", diff --git a/packages/db/package.json b/packages/db/package.json index 7fa503c..a00ff6b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,7 +12,7 @@ "lint": "echo 'db lint pending'", "typecheck": "echo 'db typecheck pending'", "db:clean": "rm -rf src/generated", - "db:generate": "bun run db:clean && prisma generate --schema ./prisma/schema.prisma", + "db:generate": "prisma generate --schema ./prisma/schema.prisma", "db:migrate": "prisma migrate deploy --schema ./prisma/schema.prisma" }, "dependencies": { diff --git a/packages/db/prisma/migrations/20260921070000_nova_early_invite/migration.sql b/packages/db/prisma/migrations/20260921070000_nova_early_invite/migration.sql new file mode 100644 index 0000000..1552cc3 --- /dev/null +++ b/packages/db/prisma/migrations/20260921070000_nova_early_invite/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "nova_early_invite" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailStatus" TEXT NOT NULL DEFAULT 'pending', + "resendEmailId" TEXT, + "emailSentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "nova_early_invite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "nova_early_invite_email_key" ON "nova_early_invite"("email"); + +-- CreateIndex +CREATE INDEX "nova_early_invite_createdAt_idx" ON "nova_early_invite"("createdAt"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c7d1b83..67b9191 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -90,6 +90,21 @@ model Review { @@map("review") } +model NovaEarlyInvite { + id String @id @default(cuid()) + name String + role String + email String @unique + emailStatus String @default("pending") // pending | sent | failed + resendEmailId String? + emailSentAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([createdAt]) + @@map("nova_early_invite") +} + model Integration { id String @id @default(cuid()) organizationId String