fix: align auth client basePath with server config
fix: align auth client basePath with server config
This commit is contained in:
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ CartSnitch is a self-hosted grocery price intelligence platform. This repo (`car
|
|||||||
| Directory | Service | Purpose |
|
| Directory | Service | Purpose |
|
||||||
|-----------|---------|---------|
|
|-----------|---------|---------|
|
||||||
| `/` (root) | Frontend | React PWA, mobile-first (this directory) |
|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) |
|
||||||
|
| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
|
||||||
| `api/` | API Gateway | Frontend-facing REST API |
|
| `api/` | API Gateway | Frontend-facing REST API |
|
||||||
| `common/` | Common | Shared Python models, schemas, Alembic migrations |
|
| `common/` | Common | Shared Python models, schemas, Alembic migrations |
|
||||||
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
|
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
|
||||||
@@ -166,9 +167,13 @@ frontend/
|
|||||||
|
|
||||||
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
|
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
|
||||||
|
|
||||||
- JWT auth: store access token in memory (not localStorage), refresh token in httpOnly cookie if possible, or secure storage.
|
- **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory.
|
||||||
|
- Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
|
||||||
|
- Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
|
||||||
|
- API gateway validates sessions by querying the shared `sessions` table in Postgres
|
||||||
|
- Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
|
||||||
- TanStack Query handles caching, background refetching, and optimistic updates.
|
- TanStack Query handles caching, background refetching, and optimistic updates.
|
||||||
- API client should handle 401 responses by attempting token refresh before retrying.
|
- API client sends `credentials: 'include'` on all requests to forward session cookies.
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
Generated
+469
-185
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"better-auth": "^1.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.0.0",
|
"react-router-dom": "^7.0.0",
|
||||||
|
|||||||
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
vi.mock('./lib/auth-client.ts', () => ({
|
||||||
|
authClient: {
|
||||||
|
useSession: () => ({ data: null, isPending: false }),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
it('renders the dashboard on the root route', () => {
|
it('renders the dashboard on the root route', () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { Navigate, Outlet } from 'react-router-dom'
|
import { Navigate, Outlet } from 'react-router-dom'
|
||||||
|
import { authClient } from '../lib/auth-client.ts'
|
||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
|
||||||
export function ProtectedRoute() {
|
export function ProtectedRoute() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
const { data: session, isPending } = authClient.useSession()
|
||||||
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
useEffect(() => {
|
||||||
|
setAuthenticated(!!session)
|
||||||
|
}, [session, setAuthenticated])
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+98
-100
@@ -1,100 +1,98 @@
|
|||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
import {
|
import {
|
||||||
mockPurchases,
|
mockPurchases,
|
||||||
mockProducts,
|
mockProducts,
|
||||||
mockCoupons,
|
mockCoupons,
|
||||||
mockAlerts,
|
mockAlerts,
|
||||||
getMockPriceHistory,
|
getMockPriceHistory,
|
||||||
} from './mock-data.ts'
|
} from './mock-data.ts'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
|
||||||
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
|
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
|
||||||
|
|
||||||
// Mock response lookup table
|
// Mock response lookup table
|
||||||
const mockRoutes: Record<string, (path: string) => unknown> = {
|
const mockRoutes: Record<string, (path: string) => unknown> = {
|
||||||
'/purchases': () => mockPurchases,
|
'/purchases': () => mockPurchases,
|
||||||
'/products': () => mockProducts,
|
'/products': () => mockProducts,
|
||||||
'/coupons': () => mockCoupons,
|
'/coupons': () => mockCoupons,
|
||||||
'/price-alerts': () => mockAlerts,
|
'/price-alerts': () => mockAlerts,
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchMockRoute<T>(path: string): T | null {
|
function matchMockRoute<T>(path: string): T | null {
|
||||||
// Exact match
|
// Exact match
|
||||||
if (mockRoutes[path]) return mockRoutes[path](path) as T
|
if (mockRoutes[path]) return mockRoutes[path](path) as T
|
||||||
|
|
||||||
// /purchases/:id
|
// /purchases/:id
|
||||||
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
|
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
|
||||||
if (purchaseMatch) {
|
if (purchaseMatch) {
|
||||||
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
|
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
|
||||||
return (purchase ?? null) as T
|
return (purchase ?? null) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
// /products/:id/price-history
|
// /products/:id/price-history
|
||||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
||||||
if (priceHistoryMatch) {
|
if (priceHistoryMatch) {
|
||||||
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
// /products?q=search or /products/:id
|
// /products/:id
|
||||||
const productMatch = path.match(/^\/products\/(.+)$/)
|
const productMatch = path.match(/^\/products\/(.+)$/)
|
||||||
if (productMatch) {
|
if (productMatch) {
|
||||||
const product = mockProducts.find((p) => p.id === productMatch[1])
|
const product = mockProducts.find((p) => p.id === productMatch[1])
|
||||||
return (product ?? null) as T
|
return (product ?? null) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
const productsSearch = path.match(/^\/products\?q=(.+)$/)
|
const productsSearch = path.match(/^\/products\?q=(.+)$/)
|
||||||
if (productsSearch) {
|
if (productsSearch) {
|
||||||
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
|
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
|
||||||
return mockProducts.filter(
|
return mockProducts.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.name.toLowerCase().includes(q) ||
|
p.name.toLowerCase().includes(q) ||
|
||||||
p.brand.toLowerCase().includes(q) ||
|
p.brand.toLowerCase().includes(q) ||
|
||||||
p.category.toLowerCase().includes(q),
|
p.category.toLowerCase().includes(q),
|
||||||
) as T
|
) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
// Mock interceptor: return mock data without hitting the network
|
// Mock interceptor: return mock data without hitting the network
|
||||||
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
|
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
|
||||||
const mockResult = matchMockRoute<T>(path)
|
const mockResult = matchMockRoute<T>(path)
|
||||||
if (mockResult !== null) {
|
if (mockResult !== null) {
|
||||||
// Simulate network delay for realistic loading states
|
// Simulate network delay for realistic loading states
|
||||||
await new Promise((r) => setTimeout(r, 300))
|
await new Promise((r) => setTimeout(r, 300))
|
||||||
return mockResult
|
return mockResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = useAuthStore.getState().token
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
credentials: 'include', // Send Better-Auth session cookie
|
||||||
...options,
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
...options?.headers,
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
},
|
||||||
...options?.headers,
|
})
|
||||||
},
|
|
||||||
})
|
if (res.status === 401) {
|
||||||
|
useAuthStore.getState().setAuthenticated(false)
|
||||||
if (res.status === 401) {
|
throw new Error('Unauthorized')
|
||||||
useAuthStore.getState().logout()
|
}
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error: ${res.status}`)
|
||||||
if (!res.ok) {
|
}
|
||||||
throw new Error(`API error: ${res.status}`)
|
|
||||||
}
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
return res.json() as Promise<T>
|
|
||||||
}
|
export const api = {
|
||||||
|
get: <T>(path: string) => apiFetch<T>(path),
|
||||||
export const api = {
|
post: <T>(path: string, body: unknown) =>
|
||||||
get: <T>(path: string) => apiFetch<T>(path),
|
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
post: <T>(path: string, body: unknown) =>
|
put: <T>(path: string, body: unknown) =>
|
||||||
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
put: <T>(path: string, body: unknown) =>
|
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||||
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
}
|
||||||
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react"
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
|
||||||
|
basePath: "/auth",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const { useSession, signIn, signUp, signOut } = authClient
|
||||||
+200
-197
@@ -1,197 +1,200 @@
|
|||||||
import React, { Suspense } from 'react'
|
import React, { Suspense } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { authClient } from '../lib/auth-client.ts'
|
||||||
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
|
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
|
||||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
const LazySparklineCard = React.lazy(() =>
|
const LazySparklineCard = React.lazy(() =>
|
||||||
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
|
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
|
||||||
)
|
)
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const { data: session, isPending } = authClient.useSession()
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
|
||||||
|
if (isPending) {
|
||||||
if (!isAuthenticated) {
|
return <DashboardSkeleton />
|
||||||
return (
|
}
|
||||||
<div className="py-8 text-center">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
|
if (!session) {
|
||||||
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
|
return (
|
||||||
<div className="mt-8 space-y-3">
|
<div className="py-8 text-center">
|
||||||
<Link
|
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
|
||||||
to="/login"
|
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
|
||||||
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
|
<div className="mt-8 space-y-3">
|
||||||
>
|
<Link
|
||||||
Sign In
|
to="/login"
|
||||||
</Link>
|
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
<Link
|
>
|
||||||
to="/register"
|
Sign In
|
||||||
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
|
</Link>
|
||||||
>
|
<Link
|
||||||
Create Account
|
to="/register"
|
||||||
</Link>
|
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
|
||||||
</div>
|
>
|
||||||
</div>
|
Create Account
|
||||||
)
|
</Link>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
return <AuthenticatedDashboard userName={user?.name ?? 'there'} />
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthenticatedDashboard({ userName }: { userName: string }) {
|
return <AuthenticatedDashboard userName={session.user?.name ?? 'there'} />
|
||||||
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
|
}
|
||||||
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
|
|
||||||
const { data: eggHistory = [] } = usePriceHistory('prod10')
|
function AuthenticatedDashboard({ userName }: { userName: string }) {
|
||||||
const { data: milkHistory = [] } = usePriceHistory('prod1')
|
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
|
||||||
|
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
|
||||||
const triggeredAlerts = alerts.filter((a) => a.triggered)
|
const { data: eggHistory = [] } = usePriceHistory('prod10')
|
||||||
const watchingAlerts = alerts.filter((a) => !a.triggered)
|
const { data: milkHistory = [] } = usePriceHistory('prod1')
|
||||||
const recentPurchases = purchases.slice(0, 3)
|
|
||||||
|
const triggeredAlerts = alerts.filter((a) => a.triggered)
|
||||||
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
|
const watchingAlerts = alerts.filter((a) => !a.triggered)
|
||||||
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
|
const recentPurchases = purchases.slice(0, 3)
|
||||||
|
|
||||||
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
|
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
|
||||||
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
|
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
|
||||||
|
|
||||||
if (purchasesLoading || alertsLoading) {
|
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
|
||||||
return <DashboardSkeleton />
|
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
|
||||||
}
|
|
||||||
|
if (purchasesLoading || alertsLoading) {
|
||||||
return (
|
return <DashboardSkeleton />
|
||||||
<div>
|
}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
|
||||||
Hi, {userName.split(' ')[0]}
|
return (
|
||||||
</h1>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
{/* Triggered alerts banner */}
|
Hi, {userName.split(' ')[0]}
|
||||||
{triggeredAlerts.length > 0 && (
|
</h1>
|
||||||
<Link
|
|
||||||
to="/alerts"
|
{/* Triggered alerts banner */}
|
||||||
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
|
{triggeredAlerts.length > 0 && (
|
||||||
>
|
<Link
|
||||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
|
to="/alerts"
|
||||||
✓
|
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
|
||||||
</span>
|
>
|
||||||
<div>
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
|
||||||
<p className="text-sm font-semibold text-green-800">
|
✓
|
||||||
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
|
</span>
|
||||||
</p>
|
<div>
|
||||||
<p className="text-xs text-green-700">
|
<p className="text-sm font-semibold text-green-800">
|
||||||
{triggeredAlerts.map((a) => a.productName).join(', ')}
|
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="text-xs text-green-700">
|
||||||
</Link>
|
{triggeredAlerts.map((a) => a.productName).join(', ')}
|
||||||
)}
|
</p>
|
||||||
|
</div>
|
||||||
{/* Quick stats */}
|
</Link>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
)}
|
||||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
|
||||||
<p className="text-xs font-medium text-gray-500">Watching</p>
|
{/* Quick stats */}
|
||||||
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
<p className="text-xs text-gray-400">price alerts</p>
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
</div>
|
<p className="text-xs font-medium text-gray-500">Watching</p>
|
||||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
|
||||||
<p className="text-xs font-medium text-gray-500">This Month</p>
|
<p className="text-xs text-gray-400">price alerts</p>
|
||||||
<p className="mt-1 text-2xl font-bold text-gray-900">
|
</div>
|
||||||
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
</p>
|
<p className="text-xs font-medium text-gray-500">This Month</p>
|
||||||
<p className="text-xs text-gray-400">grocery spend</p>
|
<p className="mt-1 text-2xl font-bold text-gray-900">
|
||||||
</div>
|
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
|
||||||
</div>
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">grocery spend</p>
|
||||||
{/* Price trend sparklines */}
|
</div>
|
||||||
<section className="mt-6">
|
</div>
|
||||||
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
|
|
||||||
<div className="space-y-3">
|
{/* Price trend sparklines */}
|
||||||
<Suspense fallback={<SparklinePlaceholder />}>
|
<section className="mt-6">
|
||||||
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
|
||||||
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
|
<div className="space-y-3">
|
||||||
</Suspense>
|
<Suspense fallback={<SparklinePlaceholder />}>
|
||||||
</div>
|
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
|
||||||
</section>
|
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
|
||||||
|
</Suspense>
|
||||||
{/* Recent purchases */}
|
</div>
|
||||||
<section className="mt-6">
|
</section>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
|
{/* Recent purchases */}
|
||||||
<Link to="/purchases" className="text-sm text-brand-blue">
|
<section className="mt-6">
|
||||||
View all
|
<div className="flex items-center justify-between">
|
||||||
</Link>
|
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
|
||||||
</div>
|
<Link to="/purchases" className="text-sm text-brand-blue">
|
||||||
<div className="mt-3 space-y-3">
|
View all
|
||||||
{recentPurchases.map((purchase) => (
|
</Link>
|
||||||
<Link
|
</div>
|
||||||
key={purchase.id}
|
<div className="mt-3 space-y-3">
|
||||||
to={`/purchases/${purchase.id}`}
|
{recentPurchases.map((purchase) => (
|
||||||
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
|
<Link
|
||||||
>
|
key={purchase.id}
|
||||||
<StoreIcon storeId={purchase.storeId} />
|
to={`/purchases/${purchase.id}`}
|
||||||
<div className="min-w-0 flex-1">
|
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
|
||||||
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
|
>
|
||||||
<p className="text-xs text-gray-500">
|
<StoreIcon storeId={purchase.storeId} />
|
||||||
{new Date(purchase.date).toLocaleDateString('en-US', {
|
<div className="min-w-0 flex-1">
|
||||||
month: 'short',
|
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
|
||||||
day: 'numeric',
|
<p className="text-xs text-gray-500">
|
||||||
})}{' '}
|
{new Date(purchase.date).toLocaleDateString('en-US', {
|
||||||
· {purchase.items.length} items
|
month: 'short',
|
||||||
</p>
|
day: 'numeric',
|
||||||
</div>
|
})}{' '}
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
· {purchase.items.length} items
|
||||||
${purchase.total.toFixed(2)}
|
</p>
|
||||||
</span>
|
</div>
|
||||||
</Link>
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
))}
|
${purchase.total.toFixed(2)}
|
||||||
</div>
|
</span>
|
||||||
</section>
|
</Link>
|
||||||
|
))}
|
||||||
{/* Quick actions */}
|
</div>
|
||||||
<section className="mt-6 pb-4">
|
</section>
|
||||||
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* Quick actions */}
|
||||||
<Link
|
<section className="mt-6 pb-4">
|
||||||
to="/products"
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
|
||||||
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
|
<div className="grid grid-cols-2 gap-3">
|
||||||
>
|
<Link
|
||||||
Compare Prices
|
to="/products"
|
||||||
</Link>
|
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
|
||||||
<Link
|
>
|
||||||
to="/settings"
|
Compare Prices
|
||||||
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
|
</Link>
|
||||||
>
|
<Link
|
||||||
Link a Store
|
to="/settings"
|
||||||
</Link>
|
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
|
||||||
</div>
|
>
|
||||||
</section>
|
Link a Store
|
||||||
</div>
|
</Link>
|
||||||
)
|
</div>
|
||||||
}
|
</section>
|
||||||
|
</div>
|
||||||
function DashboardSkeleton() {
|
)
|
||||||
return (
|
}
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-8 w-40 rounded bg-gray-200" />
|
function DashboardSkeleton() {
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
return (
|
||||||
<div className="h-24 rounded-xl bg-gray-200" />
|
<div className="animate-pulse">
|
||||||
<div className="h-24 rounded-xl bg-gray-200" />
|
<div className="h-8 w-40 rounded bg-gray-200" />
|
||||||
</div>
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
<div className="mt-6 h-5 w-28 rounded bg-gray-200" />
|
<div className="h-24 rounded-xl bg-gray-200" />
|
||||||
<div className="mt-3 space-y-3">
|
<div className="h-24 rounded-xl bg-gray-200" />
|
||||||
<div className="h-16 rounded-xl bg-gray-200" />
|
</div>
|
||||||
<div className="h-16 rounded-xl bg-gray-200" />
|
<div className="mt-6 h-5 w-28 rounded bg-gray-200" />
|
||||||
</div>
|
<div className="mt-3 space-y-3">
|
||||||
</div>
|
<div className="h-16 rounded-xl bg-gray-200" />
|
||||||
)
|
<div className="h-16 rounded-xl bg-gray-200" />
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
function SparklinePlaceholder() {
|
)
|
||||||
return (
|
}
|
||||||
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
|
|
||||||
<div className="min-w-0 flex-1">
|
function SparklinePlaceholder() {
|
||||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
return (
|
||||||
<div className="mt-2 h-6 w-16 rounded bg-gray-200" />
|
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
|
||||||
</div>
|
<div className="min-w-0 flex-1">
|
||||||
<div className="h-10 w-24 rounded bg-gray-100" />
|
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||||
</div>
|
<div className="mt-2 h-6 w-16 rounded bg-gray-200" />
|
||||||
)
|
</div>
|
||||||
}
|
<div className="h-10 w-24 rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+97
-92
@@ -1,92 +1,97 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { authClient } from '../lib/auth-client.ts'
|
||||||
import { api } from '../lib/api.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
import { mockUser } from '../lib/mock-data.ts'
|
|
||||||
import type { User } from '../types/api.ts'
|
export function Login() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
export function Login() {
|
const [password, setPassword] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
const navigate = useNavigate()
|
|
||||||
const setAuth = useAuthStore((s) => s.setAuth)
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
setError('')
|
||||||
e.preventDefault()
|
|
||||||
setError('')
|
if (!email || !password) {
|
||||||
|
setError('Please fill in all fields.')
|
||||||
if (!email || !password) {
|
return
|
||||||
setError('Please fill in all fields.')
|
}
|
||||||
return
|
|
||||||
}
|
setLoading(true)
|
||||||
|
try {
|
||||||
setLoading(true)
|
const { error: authError } = await authClient.signIn.email({
|
||||||
try {
|
email,
|
||||||
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
|
password,
|
||||||
setAuth(res.user, res.token)
|
})
|
||||||
navigate('/')
|
|
||||||
} catch {
|
if (authError) {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
throw new Error(authError.message ?? 'Sign in failed')
|
||||||
// Fallback to mock auth for demo
|
}
|
||||||
setAuth(mockUser, 'mock-jwt-token')
|
|
||||||
navigate('/')
|
setAuthenticated(true)
|
||||||
} else {
|
navigate('/')
|
||||||
setError('Invalid email or password. Please try again.')
|
} catch {
|
||||||
}
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
} finally {
|
setAuthenticated(true)
|
||||||
setLoading(false)
|
navigate('/')
|
||||||
}
|
} else {
|
||||||
}
|
setError('Invalid email or password. Please try again.')
|
||||||
|
}
|
||||||
return (
|
} finally {
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
setLoading(false)
|
||||||
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
}
|
||||||
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
}
|
||||||
|
|
||||||
{error && (
|
return (
|
||||||
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
{error}
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
||||||
</div>
|
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
||||||
)}
|
|
||||||
|
{error && (
|
||||||
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
<input
|
{error}
|
||||||
type="email"
|
</div>
|
||||||
placeholder="Email"
|
)}
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
||||||
autoComplete="email"
|
<input
|
||||||
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
type="email"
|
||||||
/>
|
placeholder="Email"
|
||||||
<input
|
value={email}
|
||||||
type="password"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Password"
|
autoComplete="email"
|
||||||
value={password}
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
/>
|
||||||
autoComplete="current-password"
|
<input
|
||||||
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
type="password"
|
||||||
/>
|
placeholder="Password"
|
||||||
<button
|
value={password}
|
||||||
type="submit"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={loading}
|
autoComplete="current-password"
|
||||||
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
>
|
/>
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
</form>
|
disabled={loading}
|
||||||
|
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
|
||||||
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
|
>
|
||||||
Forgot password?
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</Link>
|
</button>
|
||||||
|
</form>
|
||||||
<p className="mt-6 text-sm text-gray-500">
|
|
||||||
Don't have an account?{' '}
|
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
|
||||||
<Link to="/register" className="text-brand-blue">
|
Forgot password?
|
||||||
Sign up
|
</Link>
|
||||||
</Link>
|
|
||||||
</p>
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
</div>
|
Don't have an account?{' '}
|
||||||
)
|
<Link to="/register" className="text-brand-blue">
|
||||||
}
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+108
-102
@@ -1,102 +1,108 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { authClient } from '../lib/auth-client.ts'
|
||||||
import { api } from '../lib/api.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
import { mockUser } from '../lib/mock-data.ts'
|
|
||||||
import type { User } from '../types/api.ts'
|
export function Register() {
|
||||||
|
const [name, setName] = useState('')
|
||||||
export function Register() {
|
const [email, setEmail] = useState('')
|
||||||
const [name, setName] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(false)
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
const navigate = useNavigate()
|
|
||||||
const setAuth = useAuthStore((s) => s.setAuth)
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
setError('')
|
||||||
e.preventDefault()
|
|
||||||
setError('')
|
if (!name || !email || !password) {
|
||||||
|
setError('Please fill in all fields.')
|
||||||
if (!name || !email || !password) {
|
return
|
||||||
setError('Please fill in all fields.')
|
}
|
||||||
return
|
|
||||||
}
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters.')
|
||||||
if (password.length < 8) {
|
return
|
||||||
setError('Password must be at least 8 characters.')
|
}
|
||||||
return
|
|
||||||
}
|
setLoading(true)
|
||||||
|
try {
|
||||||
setLoading(true)
|
const { error: authError } = await authClient.signUp.email({
|
||||||
try {
|
name,
|
||||||
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
|
email,
|
||||||
setAuth(res.user, res.token)
|
password,
|
||||||
navigate('/')
|
})
|
||||||
} catch {
|
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (authError) {
|
||||||
// Fallback to mock auth for demo
|
throw new Error(authError.message ?? 'Registration failed')
|
||||||
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
|
}
|
||||||
navigate('/')
|
|
||||||
} else {
|
setAuthenticated(true)
|
||||||
setError('Registration failed. Please try again.')
|
navigate('/')
|
||||||
}
|
} catch {
|
||||||
} finally {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
setLoading(false)
|
setAuthenticated(true)
|
||||||
}
|
navigate('/')
|
||||||
}
|
} else {
|
||||||
|
setError('Registration failed. Please try again.')
|
||||||
return (
|
}
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
} finally {
|
||||||
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
|
setLoading(false)
|
||||||
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
|
}
|
||||||
|
}
|
||||||
{error && (
|
|
||||||
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
return (
|
||||||
{error}
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
</div>
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
|
||||||
)}
|
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
|
||||||
|
|
||||||
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
{error && (
|
||||||
<input
|
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
type="text"
|
{error}
|
||||||
placeholder="Full Name"
|
</div>
|
||||||
value={name}
|
)}
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
autoComplete="name"
|
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
||||||
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
<input
|
||||||
/>
|
type="text"
|
||||||
<input
|
placeholder="Full Name"
|
||||||
type="email"
|
value={name}
|
||||||
placeholder="Email"
|
onChange={(e) => setName(e.target.value)}
|
||||||
value={email}
|
autoComplete="name"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
autoComplete="email"
|
/>
|
||||||
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
<input
|
||||||
/>
|
type="email"
|
||||||
<input
|
placeholder="Email"
|
||||||
type="password"
|
value={email}
|
||||||
placeholder="Password (min. 8 characters)"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
value={password}
|
autoComplete="email"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
autoComplete="new-password"
|
/>
|
||||||
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
<input
|
||||||
/>
|
type="password"
|
||||||
<button
|
placeholder="Password (min. 8 characters)"
|
||||||
type="submit"
|
value={password}
|
||||||
disabled={loading}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
|
autoComplete="new-password"
|
||||||
>
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
{loading ? 'Creating account...' : 'Create Account'}
|
/>
|
||||||
</button>
|
<button
|
||||||
</form>
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
<p className="mt-6 text-sm text-gray-500">
|
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
|
||||||
Already have an account?{' '}
|
>
|
||||||
<Link to="/login" className="text-brand-blue">
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
Sign in
|
</button>
|
||||||
</Link>
|
</form>
|
||||||
</p>
|
|
||||||
</div>
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
)
|
Already have an account?{' '}
|
||||||
}
|
<Link to="/login" className="text-brand-blue">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { authClient } from '../lib/auth-client.ts'
|
||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
import { useThemeStore } from '../stores/theme.ts'
|
import { useThemeStore } from '../stores/theme.ts'
|
||||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const { data: session } = authClient.useSession()
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { theme, setTheme } = useThemeStore()
|
const { theme, setTheme } = useThemeStore()
|
||||||
|
|
||||||
const connectedStores = user?.connectedStores ?? []
|
const user = session?.user
|
||||||
|
const connectedStores: string[] = []
|
||||||
|
|
||||||
function handleSignOut() {
|
async function handleSignOut() {
|
||||||
logout()
|
await authClient.signOut()
|
||||||
|
setAuthenticated(false)
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-27
@@ -1,27 +1,18 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
|
||||||
import type { User } from '../types/api.ts'
|
/**
|
||||||
|
* Minimal auth state for UI reactivity.
|
||||||
interface AuthState {
|
*
|
||||||
user: User | null
|
* Session management is handled by Better-Auth via httpOnly cookies.
|
||||||
token: string | null
|
* This store only tracks whether we have an active session for UI
|
||||||
isAuthenticated: boolean
|
* gating (protected routes, nav state). No tokens in memory or localStorage.
|
||||||
setAuth: (user: User, token: string) => void
|
*/
|
||||||
logout: () => void
|
interface AuthState {
|
||||||
}
|
isAuthenticated: boolean
|
||||||
|
setAuthenticated: (value: boolean) => void
|
||||||
export const useAuthStore = create<AuthState>()(
|
}
|
||||||
persist(
|
|
||||||
(set) => ({
|
export const useAuthStore = create<AuthState>()((set) => ({
|
||||||
user: null,
|
isAuthenticated: false,
|
||||||
token: null,
|
setAuthenticated: (value) => set({ isAuthenticated: value }),
|
||||||
isAuthenticated: false,
|
}))
|
||||||
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
|
|
||||||
logout: () => set({ user: null, token: null, isAuthenticated: false }),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'cartsnitch-auth',
|
|
||||||
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user