diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx new file mode 100644 index 0000000..b49428c --- /dev/null +++ b/src/lib/auth.tsx @@ -0,0 +1,88 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import type { Session, User } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +export type AppRole = "admin" | "attorney" | "staff"; + +interface AuthState { + session: Session | null; + user: User | null; + roles: AppRole[]; + loading: boolean; + isAdmin: boolean; + signIn: (email: string, password: string) => Promise<{ error: string | null }>; + signOut: () => Promise; + refreshRoles: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + + const loadRoles = async (uid: string | undefined) => { + if (!uid) { + setRoles([]); + return; + } + const { data } = await supabase.from("user_roles").select("role").eq("user_id", uid); + setRoles((data ?? []).map((r) => r.role as AppRole)); + }; + + useEffect(() => { + const { data: sub } = supabase.auth.onAuthStateChange((_event, sess) => { + setSession(sess); + setUser(sess?.user ?? null); + // Defer DB call to avoid deadlock + setTimeout(() => { + loadRoles(sess?.user?.id); + }, 0); + }); + + supabase.auth.getSession().then(({ data: { session: sess } }) => { + setSession(sess); + setUser(sess?.user ?? null); + loadRoles(sess?.user?.id).finally(() => setLoading(false)); + }); + + return () => sub.subscription.unsubscribe(); + }, []); + + const signIn = async (email: string, password: string) => { + const { error } = await supabase.auth.signInWithPassword({ email, password }); + return { error: error?.message ?? null }; + }; + + const signOut = async () => { + await supabase.auth.signOut(); + setRoles([]); + }; + + const refreshRoles = async () => loadRoles(user?.id); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..055eb44 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,46 @@ +export function formatCurrency(amount: number | string | null | undefined) { + const n = typeof amount === "string" ? parseFloat(amount) : (amount ?? 0); + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0); +} + +export function formatDate(value: string | Date | null | undefined) { + if (!value) return "—"; + const d = typeof value === "string" ? new Date(value) : value; + return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); +} + +export function formatDateTime(value: string | Date | null | undefined) { + if (!value) return "—"; + const d = typeof value === "string" ? new Date(value) : value; + return d.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function statusBadgeClass(status: string) { + switch (status) { + case "active": + case "sent": + return "bg-primary/10 text-primary border-primary/20"; + case "intake": + case "draft": + return "bg-muted text-muted-foreground border-border"; + case "on_hold": + case "overdue": + return "bg-warning/15 text-warning-foreground border-warning/40"; + case "closed_won": + case "paid": + return "bg-success/15 text-success border-success/30"; + case "closed_lost": + case "void": + return "bg-destructive/10 text-destructive border-destructive/30"; + case "closed": + return "bg-secondary text-secondary-foreground border-border"; + default: + return "bg-muted text-muted-foreground border-border"; + } +} diff --git a/src/styles.css b/src/styles.css index a9806b4..cd7f01d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4,28 +4,11 @@ @custom-variant dark (&:is(.dark *)); -/* - * Design system definition. - * - * The @theme inline block maps CSS custom properties to Tailwind utility - * classes (e.g. --color-primary -> bg-primary, text-primary). - * - * The :root and .dark blocks define the actual color values using oklch. - * All colors MUST use oklch format. - * - * To add a new semantic color: - * 1. Add the variable to :root (light value) and .dark (dark value) - * 2. Register it in @theme inline as --color-: var(--) - */ - @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -42,15 +25,13 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-ring-offset-background: var(--background); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -59,86 +40,106 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --font-serif: "Source Serif 4", "Source Serif Pro", Georgia, serif; + --font-sans: "Inter", system-ui, -apple-system, sans-serif; } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.129 0.042 264.695); + --radius: 0.5rem; + + /* Surfaces — warm off-white paper */ + --background: oklch(0.985 0.005 85); + --foreground: oklch(0.18 0.025 250); + --card: oklch(1 0 0); - --card-foreground: oklch(0.129 0.042 264.695); + --card-foreground: oklch(0.18 0.025 250); + --popover: oklch(1 0 0); - --popover-foreground: oklch(0.129 0.042 264.695); - --primary: oklch(0.208 0.042 265.755); - --primary-foreground: oklch(0.984 0.003 247.858); - --secondary: oklch(0.968 0.007 247.896); - --secondary-foreground: oklch(0.208 0.042 265.755); - --muted: oklch(0.968 0.007 247.896); - --muted-foreground: oklch(0.554 0.046 257.417); - --accent: oklch(0.968 0.007 247.896); - --accent-foreground: oklch(0.208 0.042 265.755); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.984 0.003 247.858); - --border: oklch(0.929 0.013 255.508); - --input: oklch(0.929 0.013 255.508); - --ring: oklch(0.704 0.04 256.788); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.984 0.003 247.858); - --sidebar-foreground: oklch(0.129 0.042 264.695); - --sidebar-primary: oklch(0.208 0.042 265.755); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.968 0.007 247.896); - --sidebar-accent-foreground: oklch(0.208 0.042 265.755); - --sidebar-border: oklch(0.929 0.013 255.508); - --sidebar-ring: oklch(0.704 0.04 256.788); + --popover-foreground: oklch(0.18 0.025 250); + + /* Brand: deep navy ink */ + --primary: oklch(0.27 0.06 255); + --primary-foreground: oklch(0.98 0.005 85); + + --secondary: oklch(0.94 0.012 250); + --secondary-foreground: oklch(0.27 0.06 255); + + --muted: oklch(0.95 0.008 250); + --muted-foreground: oklch(0.5 0.025 250); + + --accent: oklch(0.92 0.04 80); /* subtle gold/parchment accent */ + --accent-foreground: oklch(0.27 0.06 255); + + --destructive: oklch(0.55 0.2 27); + --destructive-foreground: oklch(0.98 0.005 85); + + --success: oklch(0.55 0.13 155); + --success-foreground: oklch(0.98 0.005 85); + + --warning: oklch(0.7 0.14 75); + --warning-foreground: oklch(0.18 0.025 250); + + --border: oklch(0.9 0.012 250); + --input: oklch(0.9 0.012 250); + --ring: oklch(0.45 0.08 255); + + --sidebar: oklch(0.22 0.04 255); + --sidebar-foreground: oklch(0.92 0.012 250); + --sidebar-primary: oklch(0.62 0.15 70); + --sidebar-primary-foreground: oklch(0.18 0.025 250); + --sidebar-accent: oklch(0.28 0.05 255); + --sidebar-accent-foreground: oklch(0.96 0.008 85); + --sidebar-border: oklch(0.3 0.04 255); + --sidebar-ring: oklch(0.55 0.1 255); + + --chart-1: oklch(0.62 0.15 70); + --chart-2: oklch(0.45 0.1 255); + --chart-3: oklch(0.55 0.13 155); + --chart-4: oklch(0.55 0.2 27); + --chart-5: oklch(0.5 0.025 250); } .dark { - --background: oklch(0.129 0.042 264.695); - --foreground: oklch(0.984 0.003 247.858); - --card: oklch(0.208 0.042 265.755); - --card-foreground: oklch(0.984 0.003 247.858); - --popover: oklch(0.208 0.042 265.755); - --popover-foreground: oklch(0.984 0.003 247.858); - --primary: oklch(0.929 0.013 255.508); - --primary-foreground: oklch(0.208 0.042 265.755); - --secondary: oklch(0.279 0.041 260.031); - --secondary-foreground: oklch(0.984 0.003 247.858); - --muted: oklch(0.279 0.041 260.031); - --muted-foreground: oklch(0.704 0.04 256.788); - --accent: oklch(0.279 0.041 260.031); - --accent-foreground: oklch(0.984 0.003 247.858); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.984 0.003 247.858); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.551 0.027 264.364); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.208 0.042 265.755); - --sidebar-foreground: oklch(0.984 0.003 247.858); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.279 0.041 260.031); - --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --background: oklch(0.16 0.025 250); + --foreground: oklch(0.96 0.008 85); + --card: oklch(0.22 0.03 255); + --card-foreground: oklch(0.96 0.008 85); + --popover: oklch(0.22 0.03 255); + --popover-foreground: oklch(0.96 0.008 85); + --primary: oklch(0.85 0.04 80); + --primary-foreground: oklch(0.18 0.025 250); + --secondary: oklch(0.28 0.04 255); + --secondary-foreground: oklch(0.96 0.008 85); + --muted: oklch(0.28 0.04 255); + --muted-foreground: oklch(0.72 0.02 250); + --accent: oklch(0.32 0.05 255); + --accent-foreground: oklch(0.96 0.008 85); + --destructive: oklch(0.65 0.18 27); + --destructive-foreground: oklch(0.98 0.005 85); + --success: oklch(0.65 0.13 155); + --success-foreground: oklch(0.18 0.025 250); + --warning: oklch(0.78 0.14 75); + --warning-foreground: oklch(0.18 0.025 250); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.62 0.1 255); + --sidebar: oklch(0.18 0.03 255); + --sidebar-foreground: oklch(0.92 0.012 250); + --sidebar-primary: oklch(0.62 0.15 70); + --sidebar-primary-foreground: oklch(0.18 0.025 250); + --sidebar-accent: oklch(0.24 0.04 255); + --sidebar-accent-foreground: oklch(0.96 0.008 85); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.551 0.027 264.364); + --sidebar-ring: oklch(0.55 0.1 255); } @layer base { - * { - border-color: var(--color-border); - } - + * { border-color: var(--color-border); } body { background-color: var(--color-background); color: var(--color-foreground); + font-family: var(--font-sans); + font-feature-settings: "cv11", "ss01"; } + h1, h2, h3, h4 { font-family: var(--font-serif); letter-spacing: -0.01em; } }