feat(web): country-code picker, auth-aware header, dedupe new-server CTA
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
- login: SMS step now has a 60-country dial-code <select> (CH default) and a national-number input, combined into strict E.164 client-side - marketing header: probe /v1/auth/me, show "Dashboard" when signed in instead of the Sign in / Start building CTAs - dashboard overview: drop the duplicate "+ New server" button, the navbar one is the single source Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
88c7262a08
commit
5d0d5668d8
@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { apiFetch } from '@/lib/api';
|
|
||||||
import { StatusPill } from '@/components/status-pill';
|
import { StatusPill } from '@/components/status-pill';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface ServerRow {
|
interface ServerRow {
|
||||||
id: string;
|
id: string;
|
||||||
@ -37,19 +37,11 @@ export default function Overview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||||
<div className="flex items-baseline justify-between">
|
<div>
|
||||||
<div>
|
<h1 className="text-[22px] font-semibold tracking-tight">Overview</h1>
|
||||||
<h1 className="text-[22px] font-semibold tracking-tight">Overview</h1>
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
Your MCP servers, calls and recent builds.
|
||||||
Your MCP servers, calls and recent builds.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/servers/new"
|
|
||||||
className="inline-flex h-8 items-center gap-2 rounded-md bg-[--color-accent] px-3 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
|
||||||
>
|
|
||||||
+ New server
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||||
@ -61,7 +53,10 @@ export default function Overview() {
|
|||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Recent servers</h2>
|
<h2 className="text-[14px] font-semibold tracking-tight">Recent servers</h2>
|
||||||
<Link href="/servers" className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
|
||||||
|
>
|
||||||
View all →
|
View all →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -94,9 +89,15 @@ export default function Overview() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{servers.slice(0, 5).map((s) => (
|
{servers.slice(0, 5).map((s) => (
|
||||||
<tr key={s.id} className="border-b border-[--color-border] last:border-0 hover:bg-[--color-bg-subtle]">
|
<tr
|
||||||
|
key={s.id}
|
||||||
|
className="border-b border-[--color-border] last:border-0 hover:bg-[--color-bg-subtle]"
|
||||||
|
>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<Link href={`/servers/${s.id}`} className="font-medium hover:text-[--color-accent]">
|
<Link
|
||||||
|
href={`/servers/${s.id}`}
|
||||||
|
className="font-medium hover:text-[--color-accent]"
|
||||||
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
|
import { MarketingAuthButtons } from '@/components/marketing-auth-buttons';
|
||||||
import { MarketingMobileMenu } from '@/components/marketing-mobile-menu';
|
import { MarketingMobileMenu } from '@/components/marketing-mobile-menu';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@ -28,18 +29,7 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
<Link
|
<MarketingAuthButtons />
|
||||||
href="/login"
|
|
||||||
className="hidden rounded-md px-3 py-1.5 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:block"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
|
||||||
>
|
|
||||||
Start building
|
|
||||||
</Link>
|
|
||||||
<MarketingMobileMenu />
|
<MarketingMobileMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,7 +12,7 @@ const ERROR_COPY: Record<string, string> = {
|
|||||||
google_state: 'Google sign-in expired or was interrupted. 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_failed: 'GitHub sign-in could not be completed. Please try again.',
|
||||||
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
|
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
|
||||||
invalid_phone: 'Enter your number in international format, e.g. +41 79 123 45 67.',
|
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.',
|
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.',
|
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_or_expired_code: 'That code has expired. Request a new one.',
|
||||||
@ -21,6 +21,81 @@ const ERROR_COPY: Record<string, string> = {
|
|||||||
sms_verify_failed: 'Could not verify the code. Try again.',
|
sms_verify_failed: 'Could not verify the code. Try again.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Country dial codes for the phone-login picker. Sorted by name; Switzerland
|
||||||
|
// is the default (Swiss-built product, Swiss Twilio sender number).
|
||||||
|
const COUNTRIES: { code: string; name: string; dial: string }[] = [
|
||||||
|
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
||||||
|
{ code: 'AU', name: 'Australia', dial: '+61' },
|
||||||
|
{ code: 'AT', name: 'Austria', dial: '+43' },
|
||||||
|
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
||||||
|
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
||||||
|
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
||||||
|
{ code: 'CA', name: 'Canada', dial: '+1' },
|
||||||
|
{ code: 'CL', name: 'Chile', dial: '+56' },
|
||||||
|
{ code: 'CN', name: 'China', dial: '+86' },
|
||||||
|
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
||||||
|
{ code: 'HR', name: 'Croatia', dial: '+385' },
|
||||||
|
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
||||||
|
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
||||||
|
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
||||||
|
{ code: 'EE', name: 'Estonia', dial: '+372' },
|
||||||
|
{ code: 'FI', name: 'Finland', dial: '+358' },
|
||||||
|
{ code: 'FR', name: 'France', dial: '+33' },
|
||||||
|
{ code: 'DE', name: 'Germany', dial: '+49' },
|
||||||
|
{ code: 'GR', name: 'Greece', dial: '+30' },
|
||||||
|
{ 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: 'IE', name: 'Ireland', dial: '+353' },
|
||||||
|
{ code: 'IL', name: 'Israel', dial: '+972' },
|
||||||
|
{ code: 'IT', name: 'Italy', dial: '+39' },
|
||||||
|
{ code: 'JP', name: 'Japan', dial: '+81' },
|
||||||
|
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
||||||
|
{ code: 'LV', name: 'Latvia', dial: '+371' },
|
||||||
|
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
|
||||||
|
{ code: 'LT', name: 'Lithuania', dial: '+370' },
|
||||||
|
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
|
||||||
|
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
||||||
|
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
||||||
|
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
||||||
|
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
||||||
|
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
||||||
|
{ code: 'NO', name: 'Norway', dial: '+47' },
|
||||||
|
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
||||||
|
{ code: 'PL', name: 'Poland', dial: '+48' },
|
||||||
|
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
||||||
|
{ code: 'RO', name: 'Romania', dial: '+40' },
|
||||||
|
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
||||||
|
{ 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: 'ZA', name: 'South Africa', dial: '+27' },
|
||||||
|
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
||||||
|
{ code: 'ES', name: 'Spain', dial: '+34' },
|
||||||
|
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
||||||
|
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
||||||
|
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
||||||
|
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
||||||
|
{ 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: 'VN', name: 'Vietnam', dial: '+84' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 {
|
function errCode(err: unknown): string {
|
||||||
const detail = (err as { detail?: { error?: string } }).detail;
|
const detail = (err as { detail?: { error?: string } }).detail;
|
||||||
return detail?.error ?? (err as Error).message ?? 'unknown';
|
return detail?.error ?? (err as Error).message ?? 'unknown';
|
||||||
@ -36,7 +111,9 @@ export default function LoginPage() {
|
|||||||
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||||
|
|
||||||
// SMS one-time code
|
// SMS one-time code
|
||||||
const [phone, setPhone] = useState('');
|
const [country, setCountry] = useState('CH');
|
||||||
|
const [phoneLocal, setPhoneLocal] = useState('');
|
||||||
|
const [sentTo, setSentTo] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
|
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
|
||||||
const [smsBusy, setSmsBusy] = useState(false);
|
const [smsBusy, setSmsBusy] = useState(false);
|
||||||
@ -66,8 +143,13 @@ export default function LoginPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSmsBusy(true);
|
setSmsBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const full = toE164(dialFor(country), phoneLocal);
|
||||||
try {
|
try {
|
||||||
await apiFetch('/v1/auth/sms/request', { method: 'POST', body: JSON.stringify({ phone }) });
|
await apiFetch('/v1/auth/sms/request', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: full }),
|
||||||
|
});
|
||||||
|
setSentTo(full);
|
||||||
setSmsStep('code');
|
setSmsStep('code');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
|
||||||
@ -83,7 +165,7 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch('/v1/auth/sms/verify', {
|
await apiFetch('/v1/auth/sms/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone: sentTo, code }),
|
||||||
});
|
});
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -201,16 +283,33 @@ export default function LoginPage() {
|
|||||||
{method === 'phone' && smsStep === 'phone' && (
|
{method === 'phone' && smsStep === 'phone' && (
|
||||||
<form onSubmit={requestSmsCode} className="space-y-3">
|
<form onSubmit={requestSmsCode} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="phone">Phone number</Label>
|
<Label htmlFor="country">Country</Label>
|
||||||
|
<select
|
||||||
|
id="country"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] text-[--color-fg] transition-colors duration-200 focus:border-[--color-accent] focus:outline-none focus:ring-1 focus:ring-[--color-accent]"
|
||||||
|
>
|
||||||
|
{COUNTRIES.map((c) => (
|
||||||
|
<option key={c.code} value={c.code}>
|
||||||
|
{c.name} ({c.dial})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="phone" hint={dialFor(country)}>
|
||||||
|
Phone number
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
required
|
required
|
||||||
autoComplete="tel"
|
autoComplete="tel-national"
|
||||||
value={phone}
|
value={phoneLocal}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setPhoneLocal(e.target.value)}
|
||||||
placeholder="+41 79 123 45 67"
|
placeholder="79 123 45 67"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -228,7 +327,7 @@ export default function LoginPage() {
|
|||||||
{method === 'phone' && smsStep === 'code' && (
|
{method === 'phone' && smsStep === 'code' && (
|
||||||
<form onSubmit={verifySmsCode} className="space-y-3">
|
<form onSubmit={verifySmsCode} className="space-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="code" hint={`sent to ${phone}`}>
|
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
|
||||||
6-digit code
|
6-digit code
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
49
apps/web/components/marketing-auth-buttons.tsx
Normal file
49
apps/web/components/marketing-auth-buttons.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketing-header CTAs that reflect the session: a logged-in visitor sees a
|
||||||
|
* "Dashboard" link instead of the sign-in buttons. The session cookie is
|
||||||
|
* httpOnly, so login state is probed via /v1/auth/me.
|
||||||
|
*/
|
||||||
|
export function MarketingAuthButtons() {
|
||||||
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch('/v1/auth/me')
|
||||||
|
.then(() => setAuthed(true))
|
||||||
|
.catch(() => setAuthed(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (authed) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged out — or still resolving — show the default sign-in CTAs.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="hidden rounded-md px-3 py-1.5 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:block"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||||
|
>
|
||||||
|
Start building
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user