diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 171d5d8..05db9b4 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,3 +1,10 @@ +import { Suspense } from "react"; +import { AuthForm } from "@/components/auth/AuthForm"; + export default function Login() { - return
Login Page
; + return ( + + + + ); } diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 1592715..a9b8a3c 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,3 +1,10 @@ +import { Suspense } from "react"; +import { AuthForm } from "@/components/auth/AuthForm"; + export default function Register() { - return
Register Page
; + return ( + + + + ); } diff --git a/src/app/(dashboard)/groups/page.tsx b/src/app/(dashboard)/groups/page.tsx index 775fd9b..1b980d2 100644 --- a/src/app/(dashboard)/groups/page.tsx +++ b/src/app/(dashboard)/groups/page.tsx @@ -1,3 +1,50 @@ -export default function Groups() { - return
Groups Page
; +import { groupsService } from "@/services/api/groups"; +import { InviteButton } from "@/components/groups/InviteButton"; + +export default async function Groups() { + const groups = await groupsService.listGroups(); + + return ( +
+
+

+ Your Groups +

+

+ Manage your savings circles and invite new members. +

+
+ + +
+ ); } diff --git a/src/app/invite/[code]/page.tsx b/src/app/invite/[code]/page.tsx new file mode 100644 index 0000000..67dc2ff --- /dev/null +++ b/src/app/invite/[code]/page.tsx @@ -0,0 +1,11 @@ +import { InviteConfirm } from "@/components/groups/InviteConfirm"; + +// In Next.js 16, dynamic route `params` is a Promise and must be awaited. +export default async function InvitePage({ + params, +}: { + params: Promise<{ code: string }>; +}) { + const { code } = await params; + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 648e1ff..be7bfbd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Inter, Space_Grotesk } from "next/font/google"; import "../styles/globals.css"; +import { AuthProvider } from "@/context/AuthContext"; const inter = Inter({ subsets: ["latin"], @@ -30,7 +31,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/src/components/auth/AuthForm/AuthForm.tsx b/src/components/auth/AuthForm/AuthForm.tsx new file mode 100644 index 0000000..10d59d3 --- /dev/null +++ b/src/components/auth/AuthForm/AuthForm.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/hooks/useAuth"; + +interface AuthFormProps { + mode: "register" | "login"; +} + +/** + * Mock signup / login form. + * + * On submit it starts a mock session and honours a `?redirect=` query param so + * invite deep links return the user to the group Join confirmation after + * onboarding. Replace `login()` with a real auth call when auth lands. + * + * Reads `useSearchParams`, so it must be rendered inside a boundary + * (a Next.js requirement). + */ +export function AuthForm({ mode }: AuthFormProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { login } = useAuth(); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const isRegister = mode === "register"; + const redirect = safeRedirect(searchParams.get("redirect")); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + login({ + name: name.trim() || undefined, + email: email.trim() || undefined, + }); + router.replace(redirect ?? "/dashboard"); + } + + const otherHref = isRegister ? "/login" : "/register"; + const otherLabel = isRegister ? "Log in" : "Sign up"; + // Preserve the invite context when switching between login and signup. + const otherHrefWithRedirect = redirect + ? `${otherHref}?redirect=${encodeURIComponent(redirect)}` + : otherHref; + + return ( +
+
+

+ {isRegister ? "Create your account" : "Welcome back"} +

+

+ {isRegister + ? "Join Kolo and start saving with your community." + : "Log in to continue saving with your groups."} +

+ +
+ {isRegister && ( + + )} + + + + + + +

+ {isRegister ? "Already have an account? " : "New to Kolo? "} + + {otherLabel} + +

+
+
+ ); +} + +/** Only allow same-site relative redirects to avoid open-redirect issues. */ +function safeRedirect(value: string | null): string | null { + if (!value) return null; + // Must be a single-slash absolute path. Reject protocol-relative ("//") and + // backslash-based ("/\evil.com") forms, both of which browsers resolve to an + // external origin once handed to router.replace. + if (!value.startsWith("/") || value.startsWith("//")) return null; + if (value.includes("\\")) return null; + return value; +} + +interface FieldProps { + id: string; + label: string; + type: string; + value: string; + onChange: (value: string) => void; + autoComplete?: string; + required?: boolean; +} + +function Field({ + id, + label, + type, + value, + onChange, + autoComplete, + required, +}: FieldProps) { + return ( +
+ + onChange(event.target.value)} + className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition-colors focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> +
+ ); +} diff --git a/src/components/auth/AuthForm/index.ts b/src/components/auth/AuthForm/index.ts new file mode 100644 index 0000000..1279953 --- /dev/null +++ b/src/components/auth/AuthForm/index.ts @@ -0,0 +1 @@ +export * from "./AuthForm"; diff --git a/src/components/groups/InviteButton/InviteButton.tsx b/src/components/groups/InviteButton/InviteButton.tsx new file mode 100644 index 0000000..2cb7949 --- /dev/null +++ b/src/components/groups/InviteButton/InviteButton.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import type { Group } from "@/types/group"; +import { groupsService } from "@/services/api/groups"; +import { buildInviteUrl, buildWhatsAppShareUrl } from "@/utils/invite"; + +interface InviteButtonProps { + group: Group; +} + +type Status = "idle" | "generating" | "ready" | "error"; + +/** + * "Invite Member" button for the Group Dashboard. + * + * Generates a unique short invite link for the group, then offers the best + * available share affordance: the native Web Share sheet on supported + * (mobile) devices, plus explicit WhatsApp and copy-to-clipboard fallbacks. + */ +export function InviteButton({ group }: InviteButtonProps) { + const [status, setStatus] = useState("idle"); + const [url, setUrl] = useState(""); + const [copied, setCopied] = useState(false); + + const shareText = `Join my Kolo savings group "${group.name}" 💰`; + + async function handleGenerate() { + setStatus("generating"); + setCopied(false); + try { + const invite = await groupsService.createInvite(group.id); + setUrl(buildInviteUrl(invite.code)); + setStatus("ready"); + } catch { + setStatus("error"); + } + } + + async function handleNativeShare() { + // `navigator.share` is only available on secure origins, mostly mobile. + if (typeof navigator !== "undefined" && navigator.share) { + try { + await navigator.share({ title: "Kolo", text: shareText, url }); + } catch { + /* user dismissed the share sheet — nothing to do */ + } + } + } + + async function handleCopy() { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard blocked — the link is still visible for manual copy */ + } + } + + const canNativeShare = + typeof navigator !== "undefined" && Boolean(navigator.share); + + return ( +
+ {status !== "ready" && ( + + )} + + {status === "error" && ( +

+ Couldn't generate an invite link. Please try again. +

+ )} + + {status === "ready" && ( +
+ +
+ event.currentTarget.select()} + className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700" + /> + +
+ +
+ {canNativeShare && ( + + )} + + + + + WhatsApp + +
+ +

+ Anyone with this link can request to join{" "} + {group.name}. +

+
+ )} +
+ ); +} diff --git a/src/components/groups/InviteButton/index.ts b/src/components/groups/InviteButton/index.ts new file mode 100644 index 0000000..348d1bc --- /dev/null +++ b/src/components/groups/InviteButton/index.ts @@ -0,0 +1 @@ +export * from "./InviteButton"; diff --git a/src/components/groups/InviteConfirm/InviteConfirm.tsx b/src/components/groups/InviteConfirm/InviteConfirm.tsx new file mode 100644 index 0000000..4a84c2f --- /dev/null +++ b/src/components/groups/InviteConfirm/InviteConfirm.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import type { Group } from "@/types/group"; +import { groupsService } from "@/services/api/groups"; +import { useAuth } from "@/hooks/useAuth"; + +interface InviteConfirmProps { + code: string; +} + +type Phase = "loading" | "invalid" | "redirecting" | "confirm" | "joined"; + +/** + * The onboarding router for an invite deep link (`/invite/[code]`). + * + * 1. Resolves the invite code to its group. + * 2. Unknown / expired code -> friendly error. + * 3. Valid code + unauthenticated visitor -> routed through signup first, + * carrying the invite in `?redirect=` so we can return here afterwards. + * 4. Authenticated visitor -> group Join confirmation, then a success state. + */ +export function InviteConfirm({ code }: InviteConfirmProps) { + const router = useRouter(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + const [phase, setPhase] = useState("loading"); + const [group, setGroup] = useState(null); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + + // Resolve the invite once the auth session has hydrated. + useEffect(() => { + if (authLoading) return; + + let active = true; + + groupsService + .getInvite(code) + .then((resolution) => { + if (!active) return; + + if (!resolution) { + setPhase("invalid"); + return; + } + + setGroup(resolution.group); + + if (!isAuthenticated) { + // Preserve context: come straight back here after signup. + // Encode the whole path so codes containing &/=/# survive the round-trip. + setPhase("redirecting"); + router.replace( + `/register?redirect=${encodeURIComponent(`/invite/${code}`)}`, + ); + return; + } + + setPhase("confirm"); + }) + .catch(() => { + // Don't leave the screen spinning forever if resolution fails. + if (!active) return; + setPhase("invalid"); + }); + + return () => { + active = false; + }; + }, [authLoading, isAuthenticated, code, router]); + + async function handleJoin() { + if (!group) return; + setJoining(true); + setJoinError(null); + try { + await groupsService.joinGroup(group.id); + setPhase("joined"); + } catch { + // Keep the user on the confirm screen so they can retry. + setJoinError("Something went wrong joining the group. Please try again."); + } finally { + setJoining(false); + } + } + + return ( +
+
+ {(phase === "loading" || phase === "redirecting") && ( +
+ +

+ {phase === "redirecting" + ? "Taking you to sign up…" + : "Loading your invite…"} +

+
+ )} + + {phase === "invalid" && ( +
+

+ Invite not found +

+

+ This invite link is invalid or has expired. Ask the group admin + for a fresh link. +

+ + Back to home + +
+ )} + + {phase === "confirm" && group && ( +
+
+ + {group.name.charAt(0).toUpperCase()} + +
+

+ You've been invited to join +

+

+ {group.name} +

+ {group.description && ( +

{group.description}

+ )} + {typeof group.memberCount === "number" && ( +

+ {group.memberCount} members already saving together +

+ )} + + {joinError && ( +

+ {joinError} +

+ )} +
+ )} + + {phase === "joined" && group && ( +
+
+ + + +
+

+ Welcome to {group.name}! +

+

+ You're all set. Head to your dashboard to start contributing. +

+ + Go to dashboard + +
+ )} +
+
+ ); +} + +function Spinner() { + return ( + + ); +} diff --git a/src/components/groups/InviteConfirm/index.ts b/src/components/groups/InviteConfirm/index.ts new file mode 100644 index 0000000..43b8758 --- /dev/null +++ b/src/components/groups/InviteConfirm/index.ts @@ -0,0 +1 @@ +export * from "./InviteConfirm"; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 1101bae..c26b28a 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,2 +1,135 @@ -import { createContext } from "react"; -export const AuthContext = createContext({}); +"use client"; + +import { createContext, useMemo, useSyncExternalStore } from "react"; + +/** + * Minimal mock auth. + * + * Real authentication does not exist in the app yet, so this is a lightweight + * stand-in backed by `localStorage` (`kolo_auth`). It is enough to power the + * invite deep-link onboarding flow (route unauthenticated users through signup, + * then back to the group). Replace `login`/`logout`/`readUser` with real session + * calls once auth lands — the `useAuth` consumer contract can stay the same. + * + * The persisted session is exposed through `useSyncExternalStore` so it hydrates + * safely (server renders "loading", client reads storage) and stays in sync + * across tabs — without calling `setState` inside an effect. + */ + +export interface AuthUser { + id: string; + name: string; + email: string; +} + +export interface AuthContextValue { + user: AuthUser | null; + isAuthenticated: boolean; + /** True until the persisted session has been read on the client. */ + isLoading: boolean; + login: (user?: Partial) => void; + logout: () => void; +} + +interface AuthSnapshot { + user: AuthUser | null; + isLoading: boolean; +} + +const STORAGE_KEY = "kolo_auth"; + +export const AuthContext = createContext(null); + +// --- External auth store (module singleton) --------------------------------- + +const listeners = new Set<() => void>(); + +// Cached so `getSnapshot` returns a stable reference until storage changes, +// which `useSyncExternalStore` requires to avoid infinite re-renders. +let cachedRaw: string | null = null; +let clientSnapshot: AuthSnapshot = { user: null, isLoading: false }; +const serverSnapshot: AuthSnapshot = { user: null, isLoading: true }; + +function getClientSnapshot(): AuthSnapshot { + let raw: string | null = null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch { + raw = null; + } + if (raw !== cachedRaw) { + cachedRaw = raw; + let user: AuthUser | null = null; + try { + user = raw ? (JSON.parse(raw) as AuthUser) : null; + } catch { + user = null; + } + clientSnapshot = { user, isLoading: false }; + } + return clientSnapshot; +} + +function getServerSnapshot(): AuthSnapshot { + return serverSnapshot; +} + +function subscribe(onChange: () => void): () => void { + listeners.add(onChange); + window.addEventListener("storage", onChange); + return () => { + listeners.delete(onChange); + window.removeEventListener("storage", onChange); + }; +} + +function emit(): void { + listeners.forEach((listener) => listener()); +} + +function login(partial?: Partial): void { + // TODO: replace with real API — POST /auth/register|login + const nextUser: AuthUser = { + id: partial?.id ?? "u1", + name: partial?.name ?? "Kolo Member", + email: partial?.email ?? "member@kolo.app", + }; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextUser)); + } catch { + /* fail soft if storage is unavailable */ + } + emit(); +} + +function logout(): void { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* fail soft */ + } + emit(); +} + +// --------------------------------------------------------------------------- + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const snapshot = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + + const value = useMemo( + () => ({ + user: snapshot.user, + isAuthenticated: snapshot.user !== null, + isLoading: snapshot.isLoading, + login, + logout, + }), + [snapshot], + ); + + return {children}; +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 1a72b0c..8ce6dc5 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1 +1,13 @@ -export const useAuth = () => ({}); +"use client"; + +import { useContext } from "react"; +import { AuthContext, type AuthContextValue } from "@/context/AuthContext"; + +/** Access the current auth session. Must be used within an ``. */ +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (context === null) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/src/services/api/groups.ts b/src/services/api/groups.ts index 3029a46..435dc7a 100644 --- a/src/services/api/groups.ts +++ b/src/services/api/groups.ts @@ -1 +1,132 @@ -export const groupsService = {}; +import type { Group } from "@/types/group"; +import type { Invite, InviteResolution } from "@/types/invite"; +import { generateInviteCode } from "@/utils/invite"; + +/** + * Mock groups service. + * + * The real Kolo backend does not exist yet, so this stands in for it using + * `localStorage` for persistence. The shape of every method mirrors what a + * real API client would expose (all async), so swapping in real network calls + * later is a drop-in change at the `// TODO: replace with real API` seams. + * + * Because it relies on `localStorage`, every method is guarded for + * server-side / no-`window` environments and should only be awaited from + * client components. + */ + +const INVITES_KEY = "kolo_invites"; +const MEMBERSHIPS_KEY = "kolo_memberships"; + +/** + * Seeded demo groups so the dashboard has something to invite people to. + * TODO: replace with real API — GET /groups + */ +const SEED_GROUPS: Group[] = [ + { + id: "g1", + name: "Lagos Savers Circle", + description: "Weekly ajo for the Lagos tech community.", + memberCount: 8, + currency: "NGN", + contributionAmount: 10000, + }, + { + id: "g2", + name: "Family Emergency Fund", + description: "A safety net we build together, one month at a time.", + memberCount: 5, + currency: "NGN", + contributionAmount: 25000, + }, +]; + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +function readMap(key: string): Record { + if (!hasWindow()) return {}; + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function writeMap(key: string, value: Record): void { + if (!hasWindow()) return; + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* storage may be unavailable (private mode, quota) — fail soft */ + } +} + +export const groupsService = { + /** TODO: replace with real API — GET /groups */ + async listGroups(): Promise { + return SEED_GROUPS; + }, + + /** TODO: replace with real API — GET /groups/:id */ + async getGroup(id: string): Promise { + return SEED_GROUPS.find((group) => group.id === id) ?? null; + }, + + /** + * Generate a unique short invite code for a group and persist the mapping. + * TODO: replace with real API — POST /groups/:id/invites + */ + async createInvite(groupId: string): Promise { + const group = await this.getGroup(groupId); + if (!group) { + throw new Error(`Unknown group: ${groupId}`); + } + + const invites = readMap(INVITES_KEY); + + let code = generateInviteCode(); + // Avoid the (astronomically unlikely) collision with an existing code. + while (invites[code]) { + code = generateInviteCode(); + } + + invites[code] = groupId; + writeMap(INVITES_KEY, invites); + + return { code, groupId, createdAt: Date.now() }; + }, + + /** + * Resolve an invite code back to its group. + * TODO: replace with real API — GET /invites/:code + */ + async getInvite(code: string): Promise { + const invites = readMap(INVITES_KEY); + const groupId = invites[code]; + if (!groupId) return null; + + const group = await this.getGroup(groupId); + if (!group) return null; + + return { code, group }; + }, + + /** + * Record the current user joining a group. + * TODO: replace with real API — POST /groups/:id/members + */ + async joinGroup(groupId: string): Promise { + const memberships = readMap(MEMBERSHIPS_KEY); + memberships[groupId] = new Date().toISOString(); + writeMap(MEMBERSHIPS_KEY, memberships); + }, + + /** Whether the current browser has already joined a group. */ + async hasJoined(groupId: string): Promise { + const memberships = readMap(MEMBERSHIPS_KEY); + return Boolean(memberships[groupId]); + }, +}; diff --git a/src/types/group.ts b/src/types/group.ts index eba1cd4..0d2a295 100644 --- a/src/types/group.ts +++ b/src/types/group.ts @@ -1,3 +1,10 @@ export interface Group { id: string; + name: string; + description?: string; + memberCount?: number; + /** Currency label used for display, e.g. "NGN". */ + currency?: string; + /** Recurring contribution amount per member. */ + contributionAmount?: number; } diff --git a/src/types/invite.ts b/src/types/invite.ts new file mode 100644 index 0000000..82e2464 --- /dev/null +++ b/src/types/invite.ts @@ -0,0 +1,15 @@ +import type { Group } from "./group"; + +/** A single-use-ish invite mapping a short code to a group. */ +export interface Invite { + code: string; + groupId: string; + /** Epoch milliseconds the invite was created. */ + createdAt: number; +} + +/** A resolved invite: the short code paired with its target group. */ +export interface InviteResolution { + code: string; + group: Group; +} diff --git a/src/utils/invite.ts b/src/utils/invite.ts new file mode 100644 index 0000000..384acda --- /dev/null +++ b/src/utils/invite.ts @@ -0,0 +1,62 @@ +/** + * Pure helpers for building and sharing group invite links. + * + * These are intentionally framework-agnostic so they can be reused by the + * `InviteButton` component and by the mock `groupsService`. The only browser + * dependency (`window.location.origin`) is opt-in via the `origin` argument. + */ + +const CODE_ALPHABET = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +/** + * Generate a short, URL-safe invite code, e.g. "abc123xyz". + * + * Uses `crypto.getRandomValues` when available for good uniqueness, falling + * back to `Math.random` in environments without the Web Crypto API. + */ +export function generateInviteCode(length = 9): string { + const alphabetLength = CODE_ALPHABET.length; + + const cryptoObj = + typeof globalThis !== "undefined" ? globalThis.crypto : undefined; + + if (cryptoObj?.getRandomValues) { + const bytes = new Uint8Array(length); + cryptoObj.getRandomValues(bytes); + let code = ""; + for (let i = 0; i < length; i++) { + code += CODE_ALPHABET[bytes[i] % alphabetLength]; + } + return code; + } + + let code = ""; + for (let i = 0; i < length; i++) { + code += CODE_ALPHABET[Math.floor(Math.random() * alphabetLength)]; + } + return code; +} + +/** + * Build the absolute invite URL for a code, e.g. "https://kolo.app/invite/abc123xyz". + * + * Pass `origin` on the server; on the client it defaults to the current origin. + */ +export function buildInviteUrl(code: string, origin?: string): string { + const base = + origin ?? + (typeof window !== "undefined" + ? window.location.origin + : "https://kolo.app"); + return `${base}/invite/${code}`; +} + +/** + * Build a WhatsApp share deep link that pre-fills the given message. + * Works on both mobile (opens the app) and desktop (opens WhatsApp Web). + */ +export function buildWhatsAppShareUrl(url: string, text?: string): string { + const message = text ? `${text} ${url}` : url; + return `https://wa.me/?text=${encodeURIComponent(message)}`; +}