diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 38045a0..05433fb 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,3 +1,4 @@ +import { validateAuthCredentials } from "@/lib/auth/validation"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { NextRequest, NextResponse } from "next/server"; @@ -10,28 +11,20 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } - const { email, password } = body as { - email?: unknown; - password?: unknown; - }; - if ( - typeof email !== "string" || - typeof password !== "string" || - !email || - !password - ) { + const validation = validateAuthCredentials(body); + + if (!validation.success || !validation.credentials) { return NextResponse.json( - { error: "Email and password are required" }, + { error: validation.error ?? "Invalid credentials" }, { status: 400 } ); } const supabase = await createServerSupabaseClient(); - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }); + const { data, error } = await supabase.auth.signInWithPassword( + validation.credentials + ); if (error) { return NextResponse.json({ error: error.message }, { status: 400 }); diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f981cf5 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; + +export async function POST() { + try { + const supabase = await createServerSupabaseClient(); + const { error } = await supabase.auth.signOut(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts index f6ece3e..f3aee2d 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,9 +1,9 @@ +import { validateAuthCredentials } from "@/lib/auth/validation"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { try { - // Parse request body let body: unknown; try { body = await request.json(); @@ -14,61 +14,18 @@ export async function POST(request: NextRequest) { ); } - // Validate body is an object - if (!body || typeof body !== "object") { - return NextResponse.json( - { error: "Invalid request body" }, - { status: 400 } - ); - } - - const { email, password } = body as Record; - - // Validate email and password exist and are strings - if (typeof email !== "string" || email.trim().length === 0) { - return NextResponse.json( - { error: "Email is required and must be a string" }, - { status: 400 } - ); - } - - if (typeof password !== "string" || password.length === 0) { - return NextResponse.json( - { error: "Password is required and must be a string" }, - { status: 400 } - ); - } - - // Validate email format - simple check, Supabase will do deeper validation - const trimmedEmail = email.trim(); - if (!trimmedEmail.includes("@") || !trimmedEmail.includes(".")) { - return NextResponse.json( - { error: "Invalid email format" }, - { status: 400 } - ); - } - const [localPart, domain] = trimmedEmail.split("@"); - if (!localPart || !domain || localPart.length === 0 || domain.length < 3) { - return NextResponse.json( - { error: "Invalid email format" }, - { status: 400 } - ); - } + const validation = validateAuthCredentials(body); - // Validate password length - if (password.length < 8) { + if (!validation.success || !validation.credentials) { return NextResponse.json( - { error: "Password must be at least 8 characters" }, + { error: validation.error ?? "Invalid credentials" }, { status: 400 } ); } const supabase = await createServerSupabaseClient(); - const { data, error } = await supabase.auth.signUp({ - email: email.trim(), - password, - }); + const { data, error } = await supabase.auth.signUp(validation.credentials); if (error) { return NextResponse.json({ error: error.message }, { status: 400 }); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d1f51ea --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AuthForm } from "@/components/auth/auth-form"; +import { AuthShell } from "@/components/auth/auth-shell"; + +export default function LoginPage() { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email"); + const password = formData.get("password"); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const result = (await response.json()) as { error?: string }; + + if (!response.ok) { + setError(result.error ?? "Unable to sign in"); + return; + } + + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( + + New to Swale?{" "} + + Create an account + + + } + > + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index df268d8..3f36f7c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,525 +1,65 @@ -"use client"; - -import Link from "next/link"; -import { motion, useScroll, useTransform } from "framer-motion"; -import { - ArrowRight, - CirclePlay, - Compass, - LockKeyhole, - ShieldCheck, - Sparkles, - Waves, -} from "lucide-react"; - -const floatingNav = [ - { label: "Home", href: "/" }, - { label: "Learn more", href: "#story" }, - { label: "Documentation", href: "#path" }, -]; - -const leagueMarks = [ - { name: "Premier League", short: "PL", tone: "bg-violet-100 text-violet-700" }, - { name: "La Liga", short: "LL", tone: "bg-rose-100 text-rose-700" }, - { name: "Serie A", short: "SA", tone: "bg-sky-100 text-sky-700" }, - { name: "Bundesliga", short: "BL", tone: "bg-red-100 text-red-700" }, - { name: "Champions League", short: "CL", tone: "bg-indigo-100 text-indigo-700" }, - { name: "World Cup", short: "WC", tone: "bg-emerald-100 text-emerald-700" }, -]; - -const terminalFeed = [ - { - label: "Live pulse", - value: "Spain 2–1 France · 67' · Williams creating overloads on the left", - tone: "text-emerald-500", - }, - { - label: "Player dossier", - value: "Bellingham form trend +0.7 over last 5 · 14 progressive carries", - tone: "text-blue-600", - }, - { - label: "Match note", - value: "Momentum swing detected after 58' substitution · midfield line pushed higher", - tone: "text-sky-500", - }, - { - label: "Competition atlas", - value: "52 leagues indexed · World Cup history, club form, and live matchday context", - tone: "text-slate-700", - }, -]; - -const storyCards = [ - { - eyebrow: "Quiet at first glance", - title: "A bright, calm front door for a deeper football world.", - body: "Swale should welcome casual fans and obsessive ones alike: live scores at a glance, then deeper layers for player form, tactical context, and tournament history.", - }, - { - eyebrow: "Built for movement", - title: "Scrolling should feel guided, not flat.", - body: "A matchday product should carry momentum. The page can reveal live boards, player stories, and competition identity with the same sense of flow that makes football compelling.", - }, - { - eyebrow: "Designed for fandom", - title: "Players, clubs, and competitions deserve stronger visual identity.", - body: "From league marks to player portraits and club crests, every layer should feel considered so the product looks like football, not generic software wearing football data.", - }, -]; - -const featureGrid = [ - { - eyebrow: "01 · Matchday pulse", - title: "A live board that feels composed, not chaotic.", - body: "Follow scores, momentum swings, and key events across multiple competitions without losing the thread of what matters in each match.", - }, - { - eyebrow: "02 · Player stories", - title: "Profiles that feel closer to editorial than utility software.", - body: "Track form, role, recent output, and stylistic notes for players across club and country, with room for face popups, dossiers, and comparisons.", - }, - { - eyebrow: "03 · Competition identity", - title: "Every league and tournament should have its own presence.", - body: "The Champions League should not feel like the World Cup, and the Premier League should not feel like a generic table. Swale can give each competition its own tone while staying coherent.", - }, - { - eyebrow: "04 · Guided motion", - title: "Movement should direct attention, not ask for it.", - body: "Motion can highlight a live match, elevate a player card, or reveal a tactical note at the right moment without overwhelming the page.", - }, -]; - -function FloatingOrb({ className }: { className: string }) { - return
; -} +import Image from "next/image"; export default function Home() { - const { scrollYProgress } = useScroll(); - const heroY = useTransform(scrollYProgress, [0, 0.2], [0, 80]); - const heroOpacity = useTransform(scrollYProgress, [0, 0.18], [1, 0.72]); - const terminalY = useTransform(scrollYProgress, [0.08, 0.35], [80, -20]); - const terminalRotate = useTransform(scrollYProgress, [0.08, 0.35], [2.5, 0]); - const previewY = useTransform(scrollYProgress, [0.2, 0.5], [120, 0]); - return ( -
- - - - - - - - - - - - - -
-
-
-
- -
-
-

Swale

-

Football Intelligence

-
-
- - - -
- - Log in / Sign up - -
-
- - -
- - - Matchday clarity, crafted with restraint - - - - Football intelligence, - shaped with more taste. - - - +
+ Next.js logo + - - - - {" "} + center. +

+
+
+ -
-
-
- - - -
-
- Swale preview terminal -
-
- -
- -
-
-

Live command center

-

- Follow the matchday, the players shaping it, and the competitions around it. -

-
- - - -
- -
- {[ - { label: "Live matches", value: "12" }, - { label: "Tracked players", value: "1.2K" }, - { label: "AI summaries", value: "24/7" }, - ].map((item, index) => ( - -

{item.label}

-

{item.value}

-
- ))} -
- - -

Player interaction system

-

Hover cards, modal dossiers, and compare drawers.

-

- Open a player and immediately understand recent form, role, club context, and why that player matters in the current match or tournament. -

-
-
- - -
-
-

Pretend terminal

-

Swale OS preview

-
- - Live mock - -
- -
- {terminalFeed.map((item, index) => ( - -

{item.label}

-

{item.value}

-
- ))} -
-
-
-
- - - - - + Deploy Now +
+ -
-

Homepage preview

-

- A calmer front page with a stronger football signal. -

-

- Swale should immediately communicate what it covers: the biggest leagues, the biggest tournaments, the players in form, and the stories that shape a season. -

- -
-

- Built to span the game’s biggest stages -

-
- {leagueMarks.map((league, index) => ( - -
- {league.short} -
- {league.name} -
- ))} -
-
-
- - -
-
-
-

Coverage snapshot

-

- Built for club football, international football, and everything between. -

-
- - - -
- -
- {[ - { label: "Live matches", value: "12" }, - { label: "Tracked players", value: "1.2K" }, - { label: "Competitions", value: "52" }, - { label: "Historical seasons", value: "1930 → now" }, - ].map((item, index) => ( - -

{item.label}

-

{item.value}

-
- ))} -
-
-
- - - -
-
- {featureGrid.map((feature, index) => ( -
-

{feature.eyebrow}

-

- {feature.title} -

-

{feature.body}

-
- ))} -
-
- -
-
-
-
-

A better rhythm

-

- The page should unfold in layers, not blocks. -

-

- Instead of stacking generic sections, the homepage now moves like a guided reveal: first the promise, then the matchday view, then the wider football world of players, leagues, and tournaments. -

-
- - Scroll to move through the story -
-
-
- -
- {storyCards.map((card, index) => ( -
-

{card.eyebrow}

-

- {card.title} -

-

{card.body}

-
- ))} -
-
-
- -
-
-
-
-
-
-

Where this goes next

-

- A homepage with restraint, then a terminal with character. -

-

- The goal is simple: keep the entry experience bright and composed, then let the logged-in product become richer with live boards, player intelligence, competition history, and stronger visual identity. -

- -
- - Open dashboard - - - - - Log in / Sign up - -
-
-
- -
-
- {[ - "Sharper team and competition identity", - "Player popups that feel premium instead of generic", - "A lighter, more tasteful blue-and-white system", - "Motion that guides attention while scrolling", - ].map((item, index) => ( -
-
-
- 0{index + 1} -
-

{item}

-
-
- ))} -
-
-
-
-
-
-
+ Documentation + +
+ + ); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..99f05b7 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { AuthForm } from "@/components/auth/auth-form"; +import { AuthShell } from "@/components/auth/auth-shell"; + +export default function SignupPage() { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(null); + setIsSubmitting(true); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email"); + const password = formData.get("password"); + + try { + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const result = (await response.json()) as { + error?: string; + }; + + if (!response.ok) { + setError(result.error ?? "Unable to create account"); + return; + } + + setSuccess( + "Account created. You can now sign in with your email and password." + ); + + setTimeout(() => { + router.push("/login"); + router.refresh(); + }, 1200); + } catch { + setError("Something went wrong. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( + + Already have an account?{" "} + + Sign in + + + } + > + + + ); +} diff --git a/src/components/auth/auth-form.tsx b/src/components/auth/auth-form.tsx new file mode 100644 index 0000000..8e337c1 --- /dev/null +++ b/src/components/auth/auth-form.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { AlertCircle, Loader2 } from "lucide-react"; + +interface AuthFormProps { + mode: "login" | "signup"; + submitLabel: string; + helperText: string; + isSubmitting?: boolean; + error?: string | null; + success?: string | null; + onSubmit?: (event: React.FormEvent) => void; +} + +export function AuthForm({ + mode, + submitLabel, + helperText, + isSubmitting = false, + error, + success, + onSubmit, +}: AuthFormProps) { + return ( +
+
+

+ {mode === "login" ? "Login" : "Sign up"} +

+

+ {mode === "login" ? "Sign in to continue." : "Create your access."} +

+

{helperText}

+
+ +
+ + + +
+ + {error ? ( +
+ +

{error}

+
+ ) : null} + + {success ? ( +
+ {success} +
+ ) : null} + + +
+ ); +} diff --git a/src/components/auth/auth-shell.tsx b/src/components/auth/auth-shell.tsx new file mode 100644 index 0000000..13ebb9b --- /dev/null +++ b/src/components/auth/auth-shell.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import { Waves } from "lucide-react"; + +interface AuthShellProps { + title: string; + subtitle: string; + footer: React.ReactNode; + children: React.ReactNode; +} + +export function AuthShell({ + title, + subtitle, + footer, + children, +}: AuthShellProps) { + return ( +
+
+
+ +
+
+ +
+ +
+
+

+ Swale +

+

+ Football Intelligence +

+
+ + + + Back home + +
+ +
+
+

+ Secure access +

+

+ {title} +

+

+ {subtitle} +

+ +
+ {[ + "Live matchday boards across major competitions", + "Player form, scouting notes, and comparison views", + "World Cup history, club context, and richer football storytelling", + ].map((item) => ( +
+ {item} +
+ ))} +
+
+ +
+
+ {children} +
+ {footer} +
+
+
+
+
+
+ ); +} diff --git a/src/components/auth/logout-button.tsx b/src/components/auth/logout-button.tsx new file mode 100644 index 0000000..390a334 --- /dev/null +++ b/src/components/auth/logout-button.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; +import { useAuth } from "@/lib/hooks/useAuth"; + +export function LogoutButton() { + const router = useRouter(); + const { signOut, isSigningOut } = useAuth(); + + async function handleLogout() { + await signOut(); + router.push("/login"); + router.refresh(); + } + + return ( + + ); +} diff --git a/src/lib/auth/validation.ts b/src/lib/auth/validation.ts new file mode 100644 index 0000000..0aaa6b6 --- /dev/null +++ b/src/lib/auth/validation.ts @@ -0,0 +1,60 @@ +export interface AuthCredentials { + email: string; + password: string; +} + +export interface AuthValidationResult { + success: boolean; + error?: string; + credentials?: AuthCredentials; +} + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_PASSWORD_LENGTH = 8; + +export function validateAuthCredentials(body: unknown): AuthValidationResult { + if (!body || typeof body !== "object") { + return { + success: false, + error: "Invalid request body", + }; + } + + const { email, password } = body as Record; + + if (typeof email !== "string" || email.trim().length === 0) { + return { + success: false, + error: "Email is required", + }; + } + + if (!EMAIL_REGEX.test(email.trim())) { + return { + success: false, + error: "Invalid email format", + }; + } + + if (typeof password !== "string" || password.length === 0) { + return { + success: false, + error: "Password is required", + }; + } + + if (password.length < MIN_PASSWORD_LENGTH) { + return { + success: false, + error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, + }; + } + + return { + success: true, + credentials: { + email: email.trim().toLowerCase(), + password, + }, + }; +} diff --git a/src/lib/hooks/useAuth.ts b/src/lib/hooks/useAuth.ts index bea1c61..b92745f 100644 --- a/src/lib/hooks/useAuth.ts +++ b/src/lib/hooks/useAuth.ts @@ -1,12 +1,13 @@ "use client"; import { createClient } from "@/lib/supabase/client"; -import { useEffect, useMemo, useState } from "react"; -import type { Session } from "@supabase/supabase-js"; +import type { Session, User } from "@supabase/supabase-js"; +import { useCallback, useEffect, useMemo, useState } from "react"; export function useAuth() { const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); + const [isSigningOut, setIsSigningOut] = useState(false); const supabase = useMemo(() => createClient(), []); useEffect(() => { @@ -18,10 +19,39 @@ export function useAuth() { data: { subscription }, } = supabase.auth.onAuthStateChange((_event, session) => { setSession(session); + setLoading(false); }); - return () => subscription?.unsubscribe(); + return () => subscription.unsubscribe(); }, [supabase]); - return { session, loading }; + const signOut = useCallback(async () => { + setIsSigningOut(true); + + try { + await fetch("/api/auth/logout", { + method: "POST", + }); + await supabase.auth.signOut(); + setSession(null); + } finally { + setIsSigningOut(false); + } + }, [supabase]); + + return { + session, + user: session?.user ?? null, + loading, + isAuthenticated: Boolean(session?.user), + isSigningOut, + signOut, + } satisfies { + session: Session | null; + user: User | null; + loading: boolean; + isAuthenticated: boolean; + isSigningOut: boolean; + signOut: () => Promise; + }; } diff --git a/src/middleware.ts b/src/middleware.ts index 5102fde..2ba3126 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,21 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +const PROTECTED_ROUTES = ["/dashboard"]; +const AUTH_ROUTES = ["/login", "/signup"]; + +function isProtectedRoute(pathname: string) { + return PROTECTED_ROUTES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ); +} + +function isAuthRoute(pathname: string) { + return AUTH_ROUTES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ); +} + export async function middleware(request: NextRequest) { const supabaseResponse = NextResponse.next({ request, @@ -23,11 +38,29 @@ export async function middleware(request: NextRequest) { } ); - // Check session + let user = null; + try { - await supabase.auth.getUser(); + const { + data: { user: currentUser }, + } = await supabase.auth.getUser(); + user = currentUser; } catch { - return supabaseResponse; + user = null; + } + + const { pathname } = request.nextUrl; + + if (!user && isProtectedRoute(pathname)) { + const url = request.nextUrl.clone(); + url.pathname = "/login"; + return NextResponse.redirect(url); + } + + if (user && isAuthRoute(pathname)) { + const url = request.nextUrl.clone(); + url.pathname = "/dashboard"; + return NextResponse.redirect(url); } return supabaseResponse; @@ -35,13 +68,6 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public folder - */ - "/((?!_next/static|_next/image|favicon.ico|.*\\.svg).*)", + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|webp|gif)$).*)", ], };