diff --git a/server/trpc/index.ts b/server/trpc/index.ts index 97815ded7..2f2ec7f9a 100644 --- a/server/trpc/index.ts +++ b/server/trpc/index.ts @@ -2,6 +2,7 @@ import { createHTTPServer } from '@trpc/server/adapters/standalone'; import { projectRouter } from './project-router'; import { revisionsRouter } from './revisions-router'; import { scriptsRouter } from './scripts-router'; +import { waitlistRouter } from './waitlist-router'; import type { TRPCContext } from './types'; import type { TRPCError } from '@trpc/server'; import { t } from './router'; @@ -11,6 +12,7 @@ export const appRouter = t.router({ project: projectRouter, revisions: revisionsRouter, scripts: scriptsRouter, + waitlist: waitlistRouter, } as const); export type AppRouter = typeof appRouter; diff --git a/server/trpc/waitlist-router.ts b/server/trpc/waitlist-router.ts new file mode 100644 index 000000000..5333abbd3 --- /dev/null +++ b/server/trpc/waitlist-router.ts @@ -0,0 +1,88 @@ +import { publicProcedure } from './router'; +import { z } from 'zod'; +import { eq, sql } from 'drizzle-orm'; +import { waitlistSignups, waitlistEvents } from '../../src/db/schema'; + +function generateReferralCode(length = 8): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let code = ''; + for (let i = 0; i < length; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; +} + +export const waitlistRouter = { + signup: publicProcedure + .input(z.object({ + email: z.string().email(), + name: z.string().min(1).max(200).optional(), + source: z.string().max(100).optional().default('organic'), + referralCode: z.string().max(20).optional(), + })) + .mutation(async ({ input, ctx }) => { + const existingRows = await ctx.db!.select() + .from(waitlistSignups) + .where(eq(waitlistSignups.email, input.email.toLowerCase())); + const existing = existingRows[0]; + + if (existing) { + const metaStr = (existing as Record).metadata as string | null; + const existingMeta = metaStr ? JSON.parse(metaStr) : {}; + return { success: true, alreadyJoined: true, id: existing.id, referralCode: existingMeta.referralCode || null }; + } + + const metadata: Record = {}; + if (input.referralCode) { + metadata.referredBy = input.referralCode; + } + metadata.referralCode = generateReferralCode(); + + const result = await ctx.db!.insert(waitlistSignups) + .values({ + email: input.email.toLowerCase(), + name: input.name ?? null, + source: input.source ?? 'organic', + metadata: JSON.stringify(metadata), + }) + .returning(); + + const signup = result[0]; + + await ctx.db!.insert(waitlistEvents) + .values({ + signupId: signup!.id, + eventType: 'signup', + eventData: JSON.stringify({ source: input.source, referralCode: input.referralCode }), + }); + + const referralCode = metadata.referralCode as string; + return { success: true, alreadyJoined: false, id: signup!.id, referralCode }; + }), + + getCount: publicProcedure + .query(async ({ ctx }) => { + const result = await ctx.db!.select({ count: sql`count(*)` }) + .from(waitlistSignups) + .where(eq(waitlistSignups.status, 'waitlist')); + return { count: Number(result[0]!.count) }; + }), + + getReferralCount: publicProcedure + .input(z.object({ referralCode: z.string().min(1).max(20) })) + .query(async ({ input, ctx }) => { + const rows = await ctx.db!.select({ id: waitlistSignups.id }) + .from(waitlistSignups) + .where(eq(waitlistSignups.status, 'waitlist')); + + let count = 0; + for (const row of rows) { + const metaStr = (row as Record).metadata as string | null; + const meta = metaStr ? JSON.parse(metaStr) : {}; + if (meta.referredBy === input.referralCode) { + count++; + } + } + return { count }; + }), +}; diff --git a/src/lib/api/trpc-hooks.ts b/src/lib/api/trpc-hooks.ts index f8bf5ccfb..b4a2d2751 100644 --- a/src/lib/api/trpc-hooks.ts +++ b/src/lib/api/trpc-hooks.ts @@ -324,3 +324,32 @@ export function useDeleteScene() { }, })); } + +// Waitlist hooks +export function useWaitlistSignup() { + return createMutation(() => ({ + mutationFn: async (input: { email: string; name?: string; source?: string; referralCode?: string }) => { + return await trpc.waitlist.signup.mutate(input); + }, + })); +} + +export function useWaitlistCount() { + return createQuery(() => ({ + queryKey: ['waitlistCount'], + queryFn: async () => { + return await trpc.waitlist.getCount.query(undefined); + }, + refetchInterval: 30000, + })); +} + +export function useReferralCount(referralCode: string) { + return createQuery(() => ({ + queryKey: ['referralCount', referralCode], + queryFn: async () => { + return await trpc.waitlist.getReferralCount.query({ referralCode }); + }, + enabled: !!referralCode, + })); +} diff --git a/src/routes/landing/Landing.tsx b/src/routes/landing/Landing.tsx index a5fc197a6..05cc6b4d0 100644 --- a/src/routes/landing/Landing.tsx +++ b/src/routes/landing/Landing.tsx @@ -1,7 +1,76 @@ -import { Component, createSignal } from 'solid-js'; -import { A } from '@solidjs/router'; +import { Component, createSignal, onMount } from 'solid-js'; +import { A, useSearchParams } from '@solidjs/router'; +import { useWaitlistSignup, useWaitlistCount } from '../../lib/api/trpc-hooks'; export const Landing: Component = () => { + const [searchParams] = useSearchParams(); + const [email, setEmail] = createSignal(''); + const [name, setName] = createSignal(''); + const [submitted, setSubmitted] = createSignal(false); + const [error, setError] = createSignal(''); + const [referralCode, setReferralCode] = createSignal(''); + const [myReferralCode, setMyReferralCode] = createSignal(''); + + const signup = useWaitlistSignup(); + const count = useWaitlistCount(); + + onMount(() => { + const ref = searchParams.ref; + if (ref && typeof ref === 'string') { + setReferralCode(ref); + } + }); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + setError(''); + + if (!email().trim()) { + setError('Please enter your email address.'); + return; + } + + try { + const result = await signup.mutateAsync({ + email: email().trim(), + name: name().trim() || undefined, + source: referralCode() ? 'referral' : 'organic', + referralCode: referralCode() || undefined, + }); + if (result.referralCode) { + setMyReferralCode(result.referralCode); + } + setSubmitted(true); + } catch (err: any) { + setError(err?.message || 'Something went wrong. Please try again.'); + } + }; + + const waitlistCount = () => { + const c = count.data?.count; + if (!c) return '0'; + if (c >= 1000) return `${(c / 1000).toFixed(1)}k+`; + return String(c); + }; + + const shareUrl = () => { + if (!myReferralCode()) return ''; + return `${window.location.origin}?ref=${myReferralCode()}`; + }; + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(shareUrl()); + } catch { + const input = document.createElement('input'); + input.value = shareUrl(); + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + } + }; + return (
{/* Navigation */} @@ -19,7 +88,6 @@ export const Landing: Component = () => { Pricing Blog -
@@ -29,13 +97,57 @@ export const Landing: Component = () => {

Write Faster.

- The modern screenwriting platform built for how you actually work. + The modern screenwriting platform built for how you actually work. Real-time collaboration, AI-powered writing, and industry-standard formatting — all in one place.

-
- Start Writing Free -

No credit card required

-
+ + {submitted() ? ( +
+
🎉
+

You're on the list!

+

We'll notify you when Scripter launches. In the meantime, spread the word.

+ {myReferralCode() && ( + + )} + Explore the App +
+ ) : ( +
+
+ setName(e.currentTarget.value)} + class="form-input" + /> +
+
+ setEmail(e.currentTarget.value)} + class="form-input" + required + /> + +
+ {error() &&

{error()}

} + {referralCode() && ( +

You were referred by a friend!

+ )} +
+ )}
@@ -63,11 +175,13 @@ She doesn't look up. In the zone.`} {/* Social Proof */} @@ -122,7 +236,7 @@ She doesn't look up. In the zone.`}

Simple pricing for every screenwriter

Start free. Upgrade when you need more.

- +

Free

diff --git a/src/styles/landing.css b/src/styles/landing.css index cc6611d5c..fb0c9f22b 100644 --- a/src/styles/landing.css +++ b/src/styles/landing.css @@ -531,7 +531,143 @@ font-size: 0.875rem; } +/* Waitlist Form */ +.waitlist-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 480px; +} + +.form-row { + display: flex; + gap: 0.5rem; +} + +.form-input { + flex: 1; + padding: 0.875rem 1rem; + border: 2px solid #d1d5db; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s; + outline: none; + background: white; + color: #1a1a1a; +} + +.form-input:focus { + border-color: #518ac8; +} + +.form-error { + color: #ef4444; + font-size: 0.875rem; + margin: 0; +} + +.referral-notice { + font-size: 0.875rem; + color: #518ac8; + margin: 0; + font-weight: 500; +} + +/* Waitlist Success */ +.waitlist-success { + text-align: center; + padding: 1rem; +} + +.waitlist-success .success-icon { + font-size: 3rem; + margin-bottom: 0.5rem; +} + +.waitlist-success h3 { + font-size: 1.5rem; + color: #1a336b; + margin: 0 0 0.5rem; +} + +.waitlist-success p { + color: #666; + margin: 0 0 1.5rem; + line-height: 1.5; +} + +/* Referral Share */ +.referral-share { + background: #f0f7ff; + border: 1px solid #b3d4f0; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.referral-label { + font-weight: 600; + color: #1a336b; + margin-bottom: 0.75rem !important; +} + +.referral-link-box { + display: flex; + gap: 0.5rem; +} + +.referral-link-box .referral-input { + flex: 1; + padding: 0.625rem 0.75rem; + border: 1px solid #b3d4f0; + border-radius: 6px; + font-size: 0.875rem; + background: white; + color: #1a1a1a; +} + +.copy-btn { + padding: 0.625rem 1rem; + background: #518ac8; + color: white; + border: none; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.copy-btn:hover { + background: #3a6ca8; +} + +.referral-hint { + font-size: 0.8rem; + color: #6b7280; + margin-top: 0.75rem !important; + margin-bottom: 0 !important; +} + +/* Counter Badge */ +.counter-badge { + background: linear-gradient(135deg, #1a336b, #518ac8); + color: white !important; +} + +.counter-number { + font-size: 1.25rem; + font-weight: 700; +} + +/* Hero CTA button when used as form submit */ +.hero-cta .cta-primary:disabled { + opacity: 0.7; + cursor: not-allowed; +} + /* Responsive */ + @media (max-width: 968px) { .hero { grid-template-columns: 1fr;