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:
@@ -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;
|
||||||
|
|||||||
88
server/trpc/waitlist-router.ts
Normal file
88
server/trpc/waitlist-router.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user