Implement waitlist landing page FRE-656

- Add waitlist tRPC router with signup mutation and count query
- Add referral code generation and tracking
- Register waitlist router in app router
- Add useWaitlistSignup, useWaitlistCount, useReferralCount hooks
- Update landing page with email capture form, live waitlist counter, referral sharing
- Add waitlist and referral CSS styles

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-26 07:57:29 -04:00
parent 11a188c68e
commit ec215ae426
5 changed files with 380 additions and 11 deletions

View File

@@ -2,6 +2,7 @@ import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { projectRouter } from './project-router'; import { projectRouter } from './project-router';
import { revisionsRouter } from './revisions-router'; import { revisionsRouter } from './revisions-router';
import { scriptsRouter } from './scripts-router'; import { scriptsRouter } from './scripts-router';
import { waitlistRouter } from './waitlist-router';
import type { TRPCContext } from './types'; import type { TRPCContext } from './types';
import type { TRPCError } from '@trpc/server'; import type { TRPCError } from '@trpc/server';
import { t } from './router'; import { t } from './router';
@@ -11,6 +12,7 @@ export const appRouter = t.router({
project: projectRouter, project: projectRouter,
revisions: revisionsRouter, revisions: revisionsRouter,
scripts: scriptsRouter, scripts: scriptsRouter,
waitlist: waitlistRouter,
} as const); } as const);
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -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<string, unknown>).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<string, unknown> = {};
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<number>`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<string, unknown>).metadata as string | null;
const meta = metaStr ? JSON.parse(metaStr) : {};
if (meta.referredBy === input.referralCode) {
count++;
}
}
return { count };
}),
};

View File

@@ -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,
}));
}

View File

@@ -1,7 +1,76 @@
import { Component, createSignal } from 'solid-js'; import { Component, createSignal, onMount } from 'solid-js';
import { A } from '@solidjs/router'; import { A, useSearchParams } from '@solidjs/router';
import { useWaitlistSignup, useWaitlistCount } from '../../lib/api/trpc-hooks';
export const Landing: Component = () => { 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 ( return (
<div class="landing-page"> <div class="landing-page">
{/* Navigation */} {/* Navigation */}
@@ -19,7 +88,6 @@ export const Landing: Component = () => {
<a href="#pricing">Pricing</a> <a href="#pricing">Pricing</a>
<a href="/blog">Blog</a> <a href="/blog">Blog</a>
<A href="/sign-in" class="nav-signin">Sign In</A> <A href="/sign-in" class="nav-signin">Sign In</A>
<A href="/sign-up" class="nav-signup">Start Writing Free</A>
</div> </div>
</div> </div>
</nav> </nav>
@@ -29,13 +97,57 @@ export const Landing: Component = () => {
<div class="hero-content"> <div class="hero-content">
<h1 class="hero-headline">Write Faster.</h1> <h1 class="hero-headline">Write Faster.</h1>
<p class="hero-subheadline"> <p class="hero-subheadline">
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. Real-time collaboration, AI-powered writing, and industry-standard formatting all in one place.
</p> </p>
<div class="hero-cta">
<A href="/sign-up" class="cta-primary">Start Writing Free</A> {submitted() ? (
<p class="cta-note">No credit card required</p> <div class="waitlist-success">
</div> <div class="success-icon">🎉</div>
<h3>You're on the list!</h3>
<p>We'll notify you when Scripter launches. In the meantime, spread the word.</p>
{myReferralCode() && (
<div class="referral-share">
<p class="referral-label">Share your unique referral link:</p>
<div class="referral-link-box">
<input type="text" value={shareUrl()} readonly class="referral-input" />
<button onClick={copyToClipboard} class="copy-btn">Copy</button>
</div>
<p class="referral-hint">Earn early access perks for every friend who joins!</p>
</div>
)}
<A href="/sign-up" class="cta-primary">Explore the App</A>
</div>
) : (
<form onSubmit={handleSubmit} class="waitlist-form">
<div class="form-row">
<input
type="text"
placeholder="Your name (optional)"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
class="form-input"
/>
</div>
<div class="form-row">
<input
type="email"
placeholder="Enter your email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
class="form-input"
required
/>
<button type="submit" class="cta-primary" disabled={signup.isPending}>
{signup.isPending ? 'Joining...' : 'Join the Waitlist'}
</button>
</div>
{error() && <p class="form-error">{error()}</p>}
{referralCode() && (
<p class="referral-notice">You were referred by a friend!</p>
)}
</form>
)}
</div> </div>
<div class="hero-visual"> <div class="hero-visual">
<div class="screenshot-mockup"> <div class="screenshot-mockup">
@@ -63,11 +175,13 @@ She doesn't look up. In the zone.`}</pre>
{/* Social Proof */} {/* Social Proof */}
<section class="social-proof"> <section class="social-proof">
<p>Trusted by screenwriters everywhere</p> <p>Join the growing community of screenwriters</p>
<div class="proof-badges"> <div class="proof-badges">
<span class="badge counter-badge">
<span class="counter-number">{waitlistCount()}</span> writers on the waitlist
</span>
<span class="badge">🎬 Industry Standard</span> <span class="badge">🎬 Industry Standard</span>
<span class="badge"> 5-Star Reviews</span> <span class="badge"> 5-Star Reviews</span>
<span class="badge">🚀 Fastest Growing</span>
</div> </div>
</section> </section>
@@ -122,7 +236,7 @@ She doesn't look up. In the zone.`}</pre>
<section id="pricing" class="pricing"> <section id="pricing" class="pricing">
<h2 class="section-title">Simple pricing for every screenwriter</h2> <h2 class="section-title">Simple pricing for every screenwriter</h2>
<p class="section-subtitle">Start free. Upgrade when you need more.</p> <p class="section-subtitle">Start free. Upgrade when you need more.</p>
<div class="pricing-grid"> <div class="pricing-grid">
<div class="pricing-card"> <div class="pricing-card">
<h3>Free</h3> <h3>Free</h3>

View File

@@ -531,7 +531,143 @@
font-size: 0.875rem; 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 */ /* Responsive */
@media (max-width: 968px) { @media (max-width: 968px) {
.hero { .hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;