buildmymcpserver/apps/web/app/login/page.tsx
Marco Sadjadi 438ce3cfbc
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
feat(video): v10 hero video with mute toggle — voice + bg music
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.

## Video composition (remotion/)

Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:

- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
  excerpt, an oauth_callback.ts snippet, an .env file with a yellow
  squiggle warning ("in git history since v0.3.1"), and a live-ticking
  502 retry toast. Tangle now reads as a developer's desktop right
  before they give up, not as four icons drifting.

- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
  the parse beat: three sequential highlights on the prompt text
  (MCP server / searches / Notion workspace), three chips below the
  input (intent / tool / secret → vault), three-stat summary panel
  (tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
  global, on the voice line "the prompt path and the secret path
  never cross") a mini two-rail diagram with an explicit X-marker
  ring lands, visualising the architectural promise the moment it's
  spoken.

- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
  env-var injection beats; added the lock-snap flash at frame 66,
  pinned the vault at full opacity throughout, and added a dashed
  vault → container connector so the secret's provenance is visible.
  The "what the AI sees" panel is now 680 px wide with an eye icon,
  four corner viewfinder brackets around the prompt text, and three
  explicit denied lines (no secrets / no environment variables / no
  tokens).

- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
  card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
  container caption, <60s countdown.

- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
  chips that collided with the card and with the tokens-only badge
  are replaced by a clean vertical chip column at x=760: read-only
  filesystem · dropped capabilities · no new privileges · 512 MB
  memory cap · 0.5 CPU limit · ✓ your token only (last in green).
  A vault graphic now sits below the server card with a dashed arrow
  up into its env slot so the architecture story is complete in one
  frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
  gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
  the curious. Handshake stages simplified to your client → verified
  → scoped token. Final settlement arrow in success-green curves
  from the scoped-token pill back into the card.

- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
  with 36 px gaps. The "templates carry code, not credentials"
  sub-caption was pulled (felt on-the-nose; the detached lock and
  empty NOTION_API_KEY=? slot carry the story visually).

- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
  versions had a fake "1,200+ developers building" fork counter
  (pulled — solo-founder, hadn't earned). Replaced with a two-lane
  architecture diagram that visualises "no paths cross" literally:
  top lane prompt → AI → code, bottom lane vault → encrypted →
  env, both converging at the server box on the right. v10
  refinements: all seven boxes visible from frame 0 (no late
  server arrival), a parallel glow tour walks across both lanes
  simultaneously, a dashed vertical divider with a "no shared
  node" chip pinned in the middle, and the closing line "One
  sentence in. Live server out." slides down from above and lands
  centred while the diagram fades to 0.12 opacity behind it —
  no overlap.

- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
  loop seam.

The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.

Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.

## Web integration (apps/web/components/hero-video.tsx)

New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00

516 lines
21 KiB
TypeScript

'use client';
import { CountryPicker } from '@/components/country-picker';
import { Input, Label } from '@/components/input';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { apiFetch, apiUrl } from '@/lib/api';
import Link from 'next/link';
import { useEffect, useState } from 'react';
const ERROR_COPY: Record<string, string> = {
google_failed: 'Google sign-in could not be completed. Please try again.',
google_state: 'Google sign-in expired or was interrupted. Please try again.',
github_failed: 'GitHub sign-in could not be completed. Please try again.',
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
invalid_phone: 'That phone number does not look right. Check the country and number.',
rate_limited: 'Too many requests. Wait a few minutes and try again.',
sms_request_failed: 'Could not send the SMS. Check the number and try again.',
invalid_or_expired_code: 'That code has expired. Request a new one.',
invalid_code: 'Wrong code. Check the SMS and try again.',
too_many_attempts: 'Too many wrong attempts. Request a new code.',
sms_verify_failed: 'Could not verify the code. Try again.',
};
// Country dial codes for the phone-login picker. ~150 entries — every country
// with a non-trivial diaspora. Sorted alphabetically by name. Switzerland is
// the default (Swiss-built product, Swiss Twilio sender number).
const COUNTRIES: { code: string; name: string; dial: string }[] = [
{ code: 'AL', name: 'Albania', dial: '+355' },
{ code: 'DZ', name: 'Algeria', dial: '+213' },
{ code: 'AD', name: 'Andorra', dial: '+376' },
{ code: 'AO', name: 'Angola', dial: '+244' },
{ code: 'AR', name: 'Argentina', dial: '+54' },
{ code: 'AM', name: 'Armenia', dial: '+374' },
{ code: 'AW', name: 'Aruba', dial: '+297' },
{ code: 'AU', name: 'Australia', dial: '+61' },
{ code: 'AT', name: 'Austria', dial: '+43' },
{ code: 'AZ', name: 'Azerbaijan', dial: '+994' },
{ code: 'BS', name: 'Bahamas', dial: '+1' },
{ code: 'BH', name: 'Bahrain', dial: '+973' },
{ code: 'BD', name: 'Bangladesh', dial: '+880' },
{ code: 'BB', name: 'Barbados', dial: '+1' },
{ code: 'BY', name: 'Belarus', dial: '+375' },
{ code: 'BE', name: 'Belgium', dial: '+32' },
{ code: 'BZ', name: 'Belize', dial: '+501' },
{ code: 'BJ', name: 'Benin', dial: '+229' },
{ code: 'BM', name: 'Bermuda', dial: '+1' },
{ code: 'BT', name: 'Bhutan', dial: '+975' },
{ code: 'BO', name: 'Bolivia', dial: '+591' },
{ code: 'BA', name: 'Bosnia & Herzegovina', dial: '+387' },
{ code: 'BW', name: 'Botswana', dial: '+267' },
{ code: 'BR', name: 'Brazil', dial: '+55' },
{ code: 'BN', name: 'Brunei', dial: '+673' },
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
{ code: 'BF', name: 'Burkina Faso', dial: '+226' },
{ code: 'KH', name: 'Cambodia', dial: '+855' },
{ code: 'CM', name: 'Cameroon', dial: '+237' },
{ code: 'CA', name: 'Canada', dial: '+1' },
{ code: 'CV', name: 'Cape Verde', dial: '+238' },
{ code: 'KY', name: 'Cayman Islands', dial: '+1' },
{ code: 'CL', name: 'Chile', dial: '+56' },
{ code: 'CN', name: 'China', dial: '+86' },
{ code: 'CO', name: 'Colombia', dial: '+57' },
{ code: 'CR', name: 'Costa Rica', dial: '+506' },
{ code: 'HR', name: 'Croatia', dial: '+385' },
{ code: 'CY', name: 'Cyprus', dial: '+357' },
{ code: 'CZ', name: 'Czechia', dial: '+420' },
{ code: 'DK', name: 'Denmark', dial: '+45' },
{ code: 'DO', name: 'Dominican Republic', dial: '+1' },
{ code: 'EC', name: 'Ecuador', dial: '+593' },
{ code: 'EG', name: 'Egypt', dial: '+20' },
{ code: 'SV', name: 'El Salvador', dial: '+503' },
{ code: 'EE', name: 'Estonia', dial: '+372' },
{ code: 'ET', name: 'Ethiopia', dial: '+251' },
{ code: 'FJ', name: 'Fiji', dial: '+679' },
{ code: 'FI', name: 'Finland', dial: '+358' },
{ code: 'FR', name: 'France', dial: '+33' },
{ code: 'GE', name: 'Georgia', dial: '+995' },
{ code: 'DE', name: 'Germany', dial: '+49' },
{ code: 'GH', name: 'Ghana', dial: '+233' },
{ code: 'GR', name: 'Greece', dial: '+30' },
{ code: 'GT', name: 'Guatemala', dial: '+502' },
{ code: 'HN', name: 'Honduras', dial: '+504' },
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
{ code: 'HU', name: 'Hungary', dial: '+36' },
{ code: 'IS', name: 'Iceland', dial: '+354' },
{ code: 'IN', name: 'India', dial: '+91' },
{ code: 'ID', name: 'Indonesia', dial: '+62' },
{ code: 'IR', name: 'Iran', dial: '+98' },
{ code: 'IQ', name: 'Iraq', dial: '+964' },
{ code: 'IE', name: 'Ireland', dial: '+353' },
{ code: 'IL', name: 'Israel', dial: '+972' },
{ code: 'IT', name: 'Italy', dial: '+39' },
{ code: 'CI', name: 'Ivory Coast', dial: '+225' },
{ code: 'JM', name: 'Jamaica', dial: '+1' },
{ code: 'JP', name: 'Japan', dial: '+81' },
{ code: 'JO', name: 'Jordan', dial: '+962' },
{ code: 'KZ', name: 'Kazakhstan', dial: '+7' },
{ code: 'KE', name: 'Kenya', dial: '+254' },
{ code: 'XK', name: 'Kosovo', dial: '+383' },
{ code: 'KW', name: 'Kuwait', dial: '+965' },
{ code: 'KG', name: 'Kyrgyzstan', dial: '+996' },
{ code: 'LA', name: 'Laos', dial: '+856' },
{ code: 'LV', name: 'Latvia', dial: '+371' },
{ code: 'LB', name: 'Lebanon', dial: '+961' },
{ code: 'LY', name: 'Libya', dial: '+218' },
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
{ code: 'LT', name: 'Lithuania', dial: '+370' },
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
{ code: 'MO', name: 'Macau', dial: '+853' },
{ code: 'MK', name: 'North Macedonia', dial: '+389' },
{ code: 'MG', name: 'Madagascar', dial: '+261' },
{ code: 'MY', name: 'Malaysia', dial: '+60' },
{ code: 'MV', name: 'Maldives', dial: '+960' },
{ code: 'MT', name: 'Malta', dial: '+356' },
{ code: 'MX', name: 'Mexico', dial: '+52' },
{ code: 'MD', name: 'Moldova', dial: '+373' },
{ code: 'MC', name: 'Monaco', dial: '+377' },
{ code: 'MN', name: 'Mongolia', dial: '+976' },
{ code: 'ME', name: 'Montenegro', dial: '+382' },
{ code: 'MA', name: 'Morocco', dial: '+212' },
{ code: 'MZ', name: 'Mozambique', dial: '+258' },
{ code: 'MM', name: 'Myanmar', dial: '+95' },
{ code: 'NA', name: 'Namibia', dial: '+264' },
{ code: 'NP', name: 'Nepal', dial: '+977' },
{ code: 'NL', name: 'Netherlands', dial: '+31' },
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
{ code: 'NI', name: 'Nicaragua', dial: '+505' },
{ code: 'NG', name: 'Nigeria', dial: '+234' },
{ code: 'NO', name: 'Norway', dial: '+47' },
{ code: 'OM', name: 'Oman', dial: '+968' },
{ code: 'PK', name: 'Pakistan', dial: '+92' },
{ code: 'PS', name: 'Palestine', dial: '+970' },
{ code: 'PA', name: 'Panama', dial: '+507' },
{ code: 'PG', name: 'Papua New Guinea', dial: '+675' },
{ code: 'PY', name: 'Paraguay', dial: '+595' },
{ code: 'PE', name: 'Peru', dial: '+51' },
{ code: 'PH', name: 'Philippines', dial: '+63' },
{ code: 'PL', name: 'Poland', dial: '+48' },
{ code: 'PT', name: 'Portugal', dial: '+351' },
{ code: 'PR', name: 'Puerto Rico', dial: '+1' },
{ code: 'QA', name: 'Qatar', dial: '+974' },
{ code: 'RO', name: 'Romania', dial: '+40' },
{ code: 'RU', name: 'Russia', dial: '+7' },
{ code: 'RW', name: 'Rwanda', dial: '+250' },
{ code: 'SM', name: 'San Marino', dial: '+378' },
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
{ code: 'SN', name: 'Senegal', dial: '+221' },
{ code: 'RS', name: 'Serbia', dial: '+381' },
{ code: 'SG', name: 'Singapore', dial: '+65' },
{ code: 'SK', name: 'Slovakia', dial: '+421' },
{ code: 'SI', name: 'Slovenia', dial: '+386' },
{ code: 'SO', name: 'Somalia', dial: '+252' },
{ code: 'ZA', name: 'South Africa', dial: '+27' },
{ code: 'KR', name: 'South Korea', dial: '+82' },
{ code: 'ES', name: 'Spain', dial: '+34' },
{ code: 'LK', name: 'Sri Lanka', dial: '+94' },
{ code: 'SD', name: 'Sudan', dial: '+249' },
{ code: 'SE', name: 'Sweden', dial: '+46' },
{ code: 'CH', name: 'Switzerland', dial: '+41' },
{ code: 'SY', name: 'Syria', dial: '+963' },
{ code: 'TW', name: 'Taiwan', dial: '+886' },
{ code: 'TJ', name: 'Tajikistan', dial: '+992' },
{ code: 'TZ', name: 'Tanzania', dial: '+255' },
{ code: 'TH', name: 'Thailand', dial: '+66' },
{ code: 'TT', name: 'Trinidad & Tobago', dial: '+1' },
{ code: 'TN', name: 'Tunisia', dial: '+216' },
{ code: 'TR', name: 'Turkey', dial: '+90' },
{ code: 'TM', name: 'Turkmenistan', dial: '+993' },
{ code: 'UG', name: 'Uganda', dial: '+256' },
{ code: 'UA', name: 'Ukraine', dial: '+380' },
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
{ code: 'US', name: 'United States', dial: '+1' },
{ code: 'UY', name: 'Uruguay', dial: '+598' },
{ code: 'UZ', name: 'Uzbekistan', dial: '+998' },
{ code: 'VE', name: 'Venezuela', dial: '+58' },
{ code: 'VN', name: 'Vietnam', dial: '+84' },
{ code: 'YE', name: 'Yemen', dial: '+967' },
{ code: 'ZM', name: 'Zambia', dial: '+260' },
{ code: 'ZW', name: 'Zimbabwe', dial: '+263' },
];
function dialFor(code: string): string {
return COUNTRIES.find((c) => c.code === code)?.dial ?? '+41';
}
/** Combine a dial code and a locally-typed number into strict E.164. */
function toE164(dial: string, local: string): string {
const digits = local.replace(/\D/g, '').replace(/^0+/, '');
return dial + digits;
}
function errCode(err: unknown): string {
const detail = (err as { detail?: { error?: string } }).detail;
return detail?.error ?? (err as Error).message ?? 'unknown';
}
export default function LoginPage() {
const [providers, setProviders] = useState({
google: false,
github: false,
sms: false,
email: false,
});
// Default to SMS — email is off by default until an SMTP/Resend provider
// is wired. The effect below flips to 'email' if the backend says it's on.
const [method, setMethod] = useState<'email' | 'phone'>('phone');
const [error, setError] = useState<string | null>(null);
// Email magic-link
const [email, setEmail] = useState('');
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
// SMS one-time code
const [country, setCountry] = useState('CH');
const [phoneLocal, setPhoneLocal] = useState('');
const [sentTo, setSentTo] = useState('');
const [code, setCode] = useState('');
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
const [smsBusy, setSmsBusy] = useState(false);
useEffect(() => {
apiFetch<{ google: boolean; github: boolean; sms: boolean; email: boolean }>(
'/v1/auth/providers',
)
.then((p) => {
setProviders(p);
// Pick the most-likely method up-front: email if enabled, else SMS.
if (p.email) setMethod('email');
else if (p.sms) setMethod('phone');
})
.catch(() => undefined);
const err = new URLSearchParams(window.location.search).get('error');
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
}, []);
async function sendMagicLink(e: React.FormEvent) {
e.preventDefault();
setEmailState('sending');
setError(null);
try {
await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
setEmailState('sent');
} catch (err) {
setEmailState('idle');
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.');
}
}
async function requestSmsCode(e: React.FormEvent) {
e.preventDefault();
setSmsBusy(true);
setError(null);
const full = toE164(dialFor(country), phoneLocal);
try {
await apiFetch('/v1/auth/sms/request', {
method: 'POST',
body: JSON.stringify({ phone: full }),
});
setSentTo(full);
setSmsStep('code');
} catch (err) {
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
} finally {
setSmsBusy(false);
}
}
async function verifySmsCode(e: React.FormEvent) {
e.preventDefault();
setSmsBusy(true);
setError(null);
try {
await apiFetch('/v1/auth/sms/verify', {
method: 'POST',
body: JSON.stringify({ phone: sentTo, code }),
});
window.location.href = '/dashboard';
} catch (err) {
setError(ERROR_COPY[errCode(err)] ?? 'Could not verify the code.');
setSmsBusy(false);
}
}
const hasOAuth = providers.google || providers.github;
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="w-full max-w-sm">
<Logo className="mb-10" />
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Passwordless pick whichever is easiest.
</p>
{hasOAuth && (
<div className="mt-7 space-y-2">
{providers.google && (
<a
href={apiUrl('/v1/auth/google')}
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
>
<GoogleIcon />
Continue with Google
</a>
)}
{providers.github && (
<a
href={apiUrl('/v1/auth/github')}
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
>
<GitHubIcon />
Continue with GitHub
</a>
)}
</div>
)}
{hasOAuth && (
<div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-[--color-border]" />
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
or
</span>
<span className="h-px flex-1 bg-[--color-border]" />
</div>
)}
{/* Tab toggle only shown when BOTH email and SMS are enabled — if just
one is configured, that method's form renders directly without a
useless one-tab toggle. */}
{providers.sms && providers.email && (
<div
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
>
{(['email', 'phone'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setError(null);
}}
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
method === m
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
}`}
>
{m === 'email' ? 'Email' : 'Phone'}
</button>
))}
</div>
)}
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
{method === 'email' && providers.email && emailState !== 'sent' && (
<form onSubmit={sendMagicLink} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={emailState === 'sending'}
>
{emailState === 'sending' ? 'Sending…' : 'Send magic link'}
</Button>
</form>
)}
{method === 'email' && providers.email && emailState === 'sent' && (
<div className="panel p-4">
<p className="text-[13px]">
Magic link sent to <span className="mono">{email}</span>.
</p>
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
Open it on this device to finish signing in.
</p>
</div>
)}
{method === 'phone' && smsStep === 'phone' && (
<form onSubmit={requestSmsCode} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="country">Country</Label>
<CountryPicker
countries={COUNTRIES}
value={country}
onChange={setCountry}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="phone" hint={dialFor(country)}>
Phone number
</Label>
<Input
id="phone"
type="tel"
inputMode="tel"
required
autoComplete="tel-national"
value={phoneLocal}
onChange={(e) => setPhoneLocal(e.target.value)}
placeholder="79 123 45 67"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={smsBusy}
>
{smsBusy ? 'Sending…' : 'Send code'}
</Button>
</form>
)}
{method === 'phone' && smsStep === 'code' && (
<form onSubmit={verifySmsCode} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
6-digit code
</Label>
<Input
id="code"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="123456"
className="mono tracking-[0.3em]"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={smsBusy || code.length !== 6}
>
{smsBusy ? 'Verifying…' : 'Verify & sign in'}
</Button>
<button
type="button"
onClick={() => {
setSmsStep('phone');
setCode('');
setError(null);
}}
className="w-full text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Use a different number
</button>
</form>
)}
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
</div>
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
<Link href="/" className="transition-colors hover:text-[--color-fg]">
Back to home
</Link>
</div>
</div>
</div>
);
}
function GoogleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#4285F4"
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
/>
</svg>
);
}
function GitHubIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}