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
Binary file added apps/web/src/assets/goforlift-ship.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/src/assets/goforlift-wordmark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/src/assets/train-with-purpose.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/src/assets/welcome-starfield.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions apps/web/src/features/auth/welcome-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Presents the responsive welcome page and entry points for Google sign-in and guest access.
import { UserRound } from 'lucide-react';

import shipUrl from '../../assets/goforlift-ship.png';
import headingUrl from '../../assets/train-with-purpose.png';
import desktopStarfieldUrl from '../../assets/welcome-starfield-desktop.webp';
import starfieldUrl from '../../assets/welcome-starfield.webp';
import wordmarkUrl from '../../assets/goforlift-wordmark.png';
import { Button } from '../../components/ui/button';

export function WelcomePage() {
return (
<main className="welcome-starfield flex min-h-screen items-center justify-center overflow-hidden px-6 py-8 text-foreground sm:px-8">
<picture aria-hidden="true" className="absolute inset-0">
<source media="(min-width: 640px)" srcSet={desktopStarfieldUrl} />
<img alt="" className="size-full object-cover" src={starfieldUrl} />
</picture>
<section className="welcome-panel relative z-10 flex w-full max-w-[390px] flex-col items-center px-1 py-5 text-center sm:max-w-[520px] sm:scale-[0.82] sm:px-16 sm:py-14">
<img
alt=""
aria-hidden="true"
className="w-[135px] max-w-full sm:w-[149px]"
src={shipUrl}
/>
<img
alt="GoForLift"
className="-mt-4 w-[230px] max-w-full sm:-mt-5 sm:w-[250px]"
src={wordmarkUrl}
/>
<h1 className="-mt-4">
<img
alt="Train with purpose."
className="w-[270px] max-w-full sm:w-[290px]"
src={headingUrl}
/>
</h1>
<p className="-mt-7 max-w-[310px] text-[16px] leading-7 text-muted-foreground sm:text-[15px] sm:leading-6">
Create routines, complete guided
<br className="hidden sm:block" /> workouts, and track your progress.
</p>
<div className="mt-10 grid w-full gap-3 sm:mt-9">
<Button
asChild
className="h-14 rounded-md bg-white text-[16px] font-medium text-neutral-950 shadow-sm hover:bg-neutral-200"
>
<a href="/auth/google">
<GoogleMark />
Continue with Google
</a>
</Button>
<Button
className="h-14 rounded-md border-white/45 bg-white/[0.025] text-[16px] font-medium hover:bg-white/10"
type="button"
variant="outline"
>
<UserRound aria-hidden="true" className="size-5" />
Continue as guest
</Button>
</div>
<p className="mt-7 max-w-[260px] text-sm leading-5 text-muted-foreground">
Guest progress is temporary and will not be saved.
</p>
</section>
</main>
);
}

function GoogleMark() {
return (
<svg aria-hidden="true" className="size-5" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M21.6 12.2c0-.7-.1-1.4-.2-2H12v3.9h5.4a4.6 4.6 0 0 1-2 3v2.5h3.3c1.9-1.8 2.9-4.4 2.9-7.4Z"
/>
<path
fill="#34A853"
d="M12 22c2.7 0 5-.9 6.7-2.4l-3.3-2.5c-.9.6-2.1 1-3.4 1a5.9 5.9 0 0 1-5.5-4.1H3.1v2.6A10 10 0 0 0 12 22Z"
/>
<path
fill="#FBBC05"
d="M6.5 14a6 6 0 0 1 0-3.8V7.5H3.1a10 10 0 0 0 0 9.1L6.5 14Z"
/>
<path
fill="#EA4335"
d="M12 6.1c1.5 0 2.8.5 3.9 1.5l2.9-2.9A9.7 9.7 0 0 0 12 2a10 10 0 0 0-8.9 5.5l3.4 2.7A5.9 5.9 0 0 1 12 6.1Z"
/>
</svg>
);
}
30 changes: 12 additions & 18 deletions apps/web/src/routes/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
// Verifies the welcome page's primary entry points and guest-storage notice.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';

import { router } from '../router';

describe('foundation page', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: () => Promise.resolve({ status: 'ok', database: 'connected' }),
}),
);
});

it('renders the routed application and API status', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
describe('welcome page', () => {
it('renders the Google and guest entry points', async () => {
const queryClient = new QueryClient();
await router.navigate({ to: '/' });

render(
Expand All @@ -28,10 +18,14 @@ describe('foundation page', () => {
);

expect(
await screen.findByRole('heading', { name: /ready for liftoff/i }),
screen.getByRole('heading', { name: /train with purpose/i }),
).toBeVisible();
expect(
await screen.findByText(/api and postgresql connected/i),
).toBeVisible();
screen.getByRole('link', { name: /continue with google/i }),
).toHaveAttribute('href', '/auth/google');
expect(
screen.getByRole('button', { name: /continue as guest/i }),
).toBeEnabled();
expect(screen.getByText(/guest progress is temporary/i)).toBeVisible();
});
});
104 changes: 3 additions & 101 deletions apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,104 +1,6 @@
import { healthResponseSchema } from '@goforlift/contracts';
import { useQuery } from '@tanstack/react-query';
// Maps the root route to the public welcome page.
import { createFileRoute } from '@tanstack/react-router';
import { Activity, Orbit } from 'lucide-react';

import { Button } from '../components/ui/button';
import { WelcomePage } from '../features/auth/welcome-page';

export const Route = createFileRoute('/')({ component: HomePage });

async function getHealth() {
const response = await fetch('/health');
const data: unknown = await response.json();
return healthResponseSchema.parse(data);
}

function HomePage() {
const health = useQuery({
queryKey: ['health'],
queryFn: getHealth,
retry: false,
});

const isConnected = health.data?.database === 'connected';
const systemStatus = getSystemStatus(health.isPending, isConnected);

return (
<main className="min-h-screen bg-background px-6 py-10 text-foreground sm:px-10 lg:px-16">
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl content-center gap-10 lg:grid-cols-[1.35fr_0.65fr] lg:items-center">
<section>
<div className="mb-8 flex items-center gap-3 text-sm font-semibold tracking-[0.22em] text-primary uppercase">
<Orbit aria-hidden="true" className="size-5" />
GoForLift
</div>
<p className="mb-3 text-sm font-medium tracking-[0.18em] text-muted-foreground uppercase">
Development foundation
</p>
<h1 className="max-w-3xl text-5xl leading-none font-bold tracking-tight sm:text-7xl">
Ready for liftoff.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
The React application, typed routing, server-state client, Tailwind
design system, and Express API foundation are configured.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button onClick={() => void health.refetch()}>
Check API status
</Button>
<Button asChild variant="outline">
<a
href="https://tanstack.com/router"
rel="noreferrer"
target="_blank"
>
Router documentation
</a>
</Button>
</div>
</section>

<aside className="rounded-xl border border-border bg-surface p-6 shadow-2xl shadow-black/20">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
System status
</p>
<h2 className="mt-2 text-xl font-semibold">Foundation check</h2>
</div>
<Activity aria-hidden="true" className="size-6 text-primary" />
</div>
<div className="mt-8 flex items-center gap-3 border-t border-border pt-6">
<span
aria-hidden="true"
className={`size-2.5 rounded-full ${systemStatus.dotColor}`}
/>
<p aria-live="polite" className="font-medium">
{systemStatus.message}
</p>
</div>
</aside>
</div>
</main>
);
}

function getSystemStatus(isPending: boolean, isConnected: boolean) {
if (isPending) {
return {
dotColor: 'bg-muted-foreground',
message: 'Contacting API…',
};
}

if (isConnected) {
return {
dotColor: 'bg-primary',
message: 'API and PostgreSQL connected',
};
}

return {
dotColor: 'bg-destructive',
message: 'API or PostgreSQL unavailable',
};
}
export const Route = createFileRoute('/')({ component: WelcomePage });
24 changes: 24 additions & 0 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,27 @@ button,
a {
-webkit-tap-highlight-color: transparent;
}

.welcome-starfield {
position: relative;
background-color: #010307;
}

.welcome-panel {
border: 1px solid transparent;
}

@media (min-width: 640px) {
.welcome-panel {
border-radius: 1.5rem;
background: linear-gradient(
160deg,
rgb(11 25 36 / 50%),
rgb(6 16 24 / 56%)
);
box-shadow:
0 24px 80px rgb(0 0 0 / 45%),
inset 0 1px 0 rgb(255 255 255 / 4%);
backdrop-filter: blur(8px);
}
}
Binary file added docs/mockups/welcome-page-a1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.