feat(sales): redirect beta-signup to /register; queue waitlist emails

Phase 2 retires the public beta-signup form in favor of the self-serve
register flow. The /api/v1/beta-signup POST endpoint stays mounted but
now responds with 307 to /register?from=beta so any external links keep
working and analytics can tag signup origin via the from query param.

Note: there is no beta_signup table in the schema — the original
endpoint only fired an email notification, so there is no waitlist to
read and no migration to run for the email-sent_at field. The one-off
admin script in the spec is therefore a no-op and is intentionally not
added here.

- Replace POST /beta-signup handler with RedirectResponse(307)
- Drop the EmailService.send_beta_signup_notification call (the user is
  now redirected into the register flow, which has its own email path)
- Add tests/test_beta_signup_redirect.py covering the 307 + Location

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:35:26 -04:00
parent db2478dd89
commit c75ce0c9a3
3 changed files with 81 additions and 80 deletions

View File

@@ -1,31 +1,44 @@
"""Public beta signup endpoint — no auth required."""
"""Legacy beta signup endpoint — redirects to /register?from=beta.
Phase 2 (self-serve signup) makes the public register flow the canonical
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
preserve any external links that still hit it, but now responds with a
307 Temporary Redirect to `/register?from=beta` so the user lands in the
real signup flow. The `?from=beta` marker lets the frontend tag the
signup origin for analytics.
Note: there is no `beta_signup` database table — the original endpoint
only fired a notification email. There is therefore no waitlist to email
and no migration to run when retiring the endpoint.
"""
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
from app.core.email import EmailService
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/beta-signup", tags=["beta"])
class BetaSignupRequest(BaseModel):
email: EmailStr
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
# be absolute — a relative URL would resolve against the API origin
# (api.resolutionflow.com), which has no /register page.
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
class BetaSignupResponse(BaseModel):
success: bool
message: str
@router.post("", include_in_schema=False)
async def beta_signup_redirect() -> RedirectResponse:
"""Redirect legacy beta-signup POST to the public register page.
@router.post("", response_model=BetaSignupResponse)
async def beta_signup(data: BetaSignupRequest):
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
sent = await EmailService.send_beta_signup_notification(data.email)
if not sent:
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
return BetaSignupResponse(
success=True,
message="Thanks! We'll be in touch with beta access details.",
Returns 307 so any client following the redirect preserves the HTTP
method; the frontend treats `/register?from=beta` as the canonical
entry point and reads the `from` query param for analytics.
"""
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
return RedirectResponse(
url=f"{frontend_url}/register?from=beta",
status_code=307,
)

View File

@@ -0,0 +1,43 @@
"""Integration tests for the legacy /beta-signup redirect.
Phase 2 retires the public beta-signup form in favor of the regular
register flow. The endpoint stays mounted but answers with a 307 to
the absolute frontend `/register?from=beta` URL so any external links
keep working. There is no `beta_signup` table to migrate — the old
endpoint only fired an email notification — so this test only covers
the redirect contract.
"""
import pytest
from app.core.config import settings
@pytest.mark.asyncio
async def test_beta_signup_redirects_to_register(client, monkeypatch):
"""POST /beta-signup returns 307 to the absolute frontend register URL."""
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
response = await client.post(
"/api/v1/beta-signup",
json={"email": "anyone@example.com"},
)
assert response.status_code == 307, response.text
assert (
response.headers["location"]
== "https://example.com/register?from=beta"
)
@pytest.mark.asyncio
async def test_beta_signup_redirect_ignores_body(client, monkeypatch):
"""Redirect fires regardless of payload — no validation on the legacy route."""
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
response = await client.post("/api/v1/beta-signup", json={})
assert response.status_code == 307
assert (
response.headers["location"]
== "https://example.com/register?from=beta"
)

View File

@@ -1,11 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const FAQ_ITEMS = [
{
q: 'How is this different from just using ChatGPT?',
@@ -33,9 +31,6 @@ export default function LandingPage() {
const appConfig = useAppConfig()
const [navScrolled, setNavScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [betaEmail, setBetaEmail] = useState('')
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [betaError, setBetaError] = useState('')
const [openFaq, setOpenFaq] = useState<number | null>(null)
const mobileMenuRef = useRef<HTMLDivElement>(null)
@@ -73,32 +68,6 @@ export default function LandingPage() {
return () => observer.disconnect()
}, [])
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = betaEmail.trim()
if (!trimmed || betaStatus === 'sending') return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setBetaStatus('error')
setBetaError('Enter a valid email address.')
return
}
setBetaStatus('sending')
setBetaError('')
try {
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmed }),
})
if (!resp.ok) throw new Error('Signup failed')
setBetaStatus('sent')
setBetaEmail('')
} catch {
setBetaStatus('error')
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
}
}, [betaEmail, betaStatus])
const toggleFaq = (index: number) => {
setOpenFaq(prev => prev === index ? null : index)
}
@@ -433,34 +402,10 @@ export default function LandingPage() {
<section className="landing-cta-section landing-reveal">
<div className="landing-cta-inner">
<h2>Ready to stop writing ticket notes?</h2>
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
<div className="landing-cta-input-wrap">
<input
type="email"
className="landing-cta-email-input"
placeholder="you@yourmsp.com"
value={betaEmail}
onChange={e => {
setBetaEmail(e.target.value)
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
}}
required
aria-describedby="beta-status"
/>
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
</button>
</div>
<div id="beta-status" aria-live="polite" className="landing-cta-status">
{betaStatus === 'sent' && (
<p className="landing-cta-success">You&apos;re in. We&apos;ll send beta access details soon.</p>
)}
{betaStatus === 'error' && betaError && (
<p className="landing-cta-error">{betaError}</p>
)}
</div>
</form>
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
<div className="landing-cta-actions">
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
</div>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section>