Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/web/src/components/account/account-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { type ReactNode } from "react";

import { Notice } from "@vellum/design-library";

const ARROW_ICON = (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.5 15L12.5 10L7.5 5"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

interface AccountFormProps {
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
error?: string | null;
children: ReactNode;
submitLabel: string;
submittingLabel: string;
isSubmitting: boolean;
footer?: ReactNode;
}

export function AccountForm({
onSubmit,
error,
children,
submitLabel,
submittingLabel,
isSubmitting,
footer,
}: AccountFormProps) {
return (
<>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
{error && <Notice tone="error">{error}</Notice>}

<div className="flex flex-col gap-3">{children}</div>

<button
type="submit"
disabled={isSubmitting}
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[var(--primary-base)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)] disabled:cursor-wait disabled:opacity-50"
>
{isSubmitting ? submittingLabel : submitLabel}
{ARROW_ICON}
</button>
</form>

{footer && <div className="mt-8 text-center">{footer}</div>}
</>
);
}

export function AccountInput(
props: React.InputHTMLAttributes<HTMLInputElement>,
) {
return (
<input
{...props}
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none placeholder:text-stone-500 focus:border-forest-600/50"
/>
);
}

export function AccountHeading({
title,
subtitle,
}: {
title: string;
subtitle?: ReactNode;
}) {
return (
<div className="mb-8 text-center">
<h1 className="mb-2 font-serif text-[2rem] font-bold italic text-white">
{title}
</h1>
{subtitle && <p className="text-sm text-stone-400">{subtitle}</p>}
</div>
);
}
17 changes: 17 additions & 0 deletions apps/web/src/components/account/account-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type ReactNode } from "react";

interface AccountShellProps {
children: ReactNode;
}

export function AccountShell({ children }: AccountShellProps) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0d0d0d]">
<div className="w-full max-w-[480px] px-6">
<div className="flex flex-col items-center gap-10">
<div className="w-full">{children}</div>
</div>
</div>
</div>
);
}
38 changes: 38 additions & 0 deletions apps/web/src/components/icons/google-logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { CSSProperties } from "react";

interface GoogleLogoProps {
size?: number;
className?: string;
style?: CSSProperties;
}

export function GoogleLogo({ size = 24, className, style }: GoogleLogoProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
width={size}
height={size}
className={className}
style={style}
aria-hidden
>
<path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<path
fill="#FBBC05"
d="M10.53 28.59a14.5 14.5 0 0 1 0-9.18l-7.97-6.19A23.99 23.99 0 0 0 0 24c0 3.77.9 7.35 2.56 10.78l7.97-6.19z"
/>
<path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</svg>
);
}
45 changes: 45 additions & 0 deletions apps/web/src/components/native-splash.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";

/**
* Full-screen branded splash shown on native iOS during:
* - Initial login (behind the ASWebAuthenticationSession Safari sheet)
* - Biometric session recovery (while Face ID / Touch ID is prompting)
* - Session validation (while checking if the user is still logged in)
*
* Centers the Vellum wordmark vertically and displays the character
* illustrations flush at the bottom of the screen.
*/
export function NativeSplash({ children }: { children?: ReactNode }) {
return (
<div className="app-root fixed inset-0 z-50 flex flex-col items-center justify-center bg-[var(--surface-base)] text-[var(--content-default)]">
<img
src="/vellum-logo.svg"
alt="Vellum"
width={220}
height={66}
className="block dark:hidden"
/>
<img
src="/vellum-logo-white.svg"
alt="Vellum"
width={220}
height={66}
className="hidden dark:block"
/>
{children}
<div
aria-hidden
className="pointer-events-none absolute left-1/2 w-full max-w-[900px] -translate-x-1/2"
style={{ bottom: 0 }}
>
<img
src="/login-background-characters.svg"
alt=""
width={880}
height={182}
className="h-auto w-full"
/>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/components/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Link } from "react-router";

import { routes } from "@/utils/routes.js";

export function NotFound() {
return (
<section>
<h2>Not found</h2>
<p>
The page you requested does not exist. <Link to="/">Start a new conversation</Link>.
The page you requested does not exist. <Link to={routes.assistant}>Start a new conversation</Link>.
</p>
</section>
);
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/domains/account/components/login-background.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Decorative background for the branded `/account/login` screen.
*
* Renders the full-white Vellum wordmark and the login background characters
* SVG anchored to the bottom edge. Purely presentational (`pointer-events-none`)
* so the form above stays fully interactive.
*/
export function LoginBackground() {
return (
<>
<div className="pointer-events-none absolute top-[120px] left-1/2 z-0 -translate-x-1/2">
<img
src="/vellum-logo-white.svg"
alt="Vellum"
width={92}
height={28}
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute right-0 bottom-0 left-1/2 z-0 w-full max-w-[1100px] -translate-x-1/2"
>
<img
src="/login-background-characters.svg"
alt=""
width={880}
height={182}
className="h-auto w-full"
/>
</div>
</>
);
}
72 changes: 72 additions & 0 deletions apps/web/src/domains/account/pages/account-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Link, useNavigate } from "react-router";

import { AccountHeading } from "@/components/account/account-form.js";
import { AccountShell } from "@/components/account/account-shell.js";
import { PROVIDER_CALLBACK_URL, PROVIDER_ID } from "@/lib/account/login-flow.js";
import { startAuthFlow } from "@/runtime/native-auth.js";
import { useAuth } from "@/lib/auth/auth-provider.js";
import { routes } from "@/utils/routes.js";

/**
* Account landing page. Shows a sign-in CTA when unauthenticated,
* or a "Go to your assistant" link + sign-out button when logged in.
*/
export function AccountPage() {
const navigate = useNavigate();
const { isLoggedIn, isLoading, username, logout } = useAuth();

if (isLoading) {
return (
<AccountShell>
<AccountHeading title="Loading..." />
</AccountShell>
);
}

if (!isLoggedIn) {
return (
<AccountShell>
<AccountHeading
title="Welcome to Vellum"
subtitle="Sign in to get started."
/>
<div className="flex flex-col items-center gap-4">
<button
type="button"
onClick={() => void startAuthFlow(PROVIDER_ID, PROVIDER_CALLBACK_URL)}
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[var(--primary-base)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)]"
>
Sign in
</button>
</div>
</AccountShell>
);
}

return (
<AccountShell>
<AccountHeading
title={`Welcome${username ? `, ${username}` : ""}!`}
subtitle="You are signed in."
/>
<div className="flex flex-col items-center gap-4">
<Link
to={routes.assistant}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-[var(--primary-base)] px-6 py-3 text-sm font-medium text-white no-underline transition-colors hover:bg-[var(--primary-hover)]"
>
Go to your assistant
</Link>
<button
type="button"
onClick={async () => {
await logout();
navigate(routes.account.login);
}}
className="cursor-pointer bg-transparent text-sm font-normal text-stone-400 transition-colors hover:text-stone-300"
>
Sign out
</button>
</div>
</AccountShell>
);
}
Loading