Merge pull request #13 from fullsizemalt/claude/setup-web-foundation-01U2Zo7smNxkTBwUR8A6rZsk
Set up web project and begin foundation components
This commit is contained in:
commit
e5927b986d
54 changed files with 9135 additions and 5 deletions
3
web/.next/app-build-manifest.json
Normal file
3
web/.next/app-build-manifest.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"pages": {}
|
||||||
|
}
|
||||||
16
web/.next/build-manifest.json
Normal file
16
web/.next/build-manifest.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"polyfillFiles": [
|
||||||
|
"static/chunks/polyfills.js"
|
||||||
|
],
|
||||||
|
"devFiles": [],
|
||||||
|
"ampDevFiles": [],
|
||||||
|
"lowPriorityFiles": [
|
||||||
|
"static/development/_buildManifest.js",
|
||||||
|
"static/development/_ssgManifest.js"
|
||||||
|
],
|
||||||
|
"rootMainFiles": [],
|
||||||
|
"pages": {
|
||||||
|
"/_app": []
|
||||||
|
},
|
||||||
|
"ampFirstPages": []
|
||||||
|
}
|
||||||
7
web/.next/cache/config.json
vendored
Normal file
7
web/.next/cache/config.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"telemetry": {
|
||||||
|
"notifiedAt": "1763427314021",
|
||||||
|
"anonymousId": "70c983e3a214885f150e4f5a0f0894388c5b95a67ab9be573a2ea0500313ea48",
|
||||||
|
"salt": "834e7078c6f3d753930f132743a3f917"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
BIN
web/.next/cache/webpack/client-development/0.pack.gz
vendored
Normal file
Binary file not shown.
BIN
web/.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
BIN
web/.next/cache/webpack/client-development/index.pack.gz
vendored
Normal file
Binary file not shown.
1
web/.next/package.json
Normal file
1
web/.next/package.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"type": "commonjs"}
|
||||||
1
web/.next/react-loadable-manifest.json
Normal file
1
web/.next/react-loadable-manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
1
web/.next/server/app-paths-manifest.json
Normal file
1
web/.next/server/app-paths-manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
1
web/.next/server/interception-route-rewrite-manifest.js
Normal file
1
web/.next/server/interception-route-rewrite-manifest.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||||
18
web/.next/server/middleware-build-manifest.js
Normal file
18
web/.next/server/middleware-build-manifest.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
self.__BUILD_MANIFEST = {
|
||||||
|
"polyfillFiles": [
|
||||||
|
"static/chunks/polyfills.js"
|
||||||
|
],
|
||||||
|
"devFiles": [],
|
||||||
|
"ampDevFiles": [],
|
||||||
|
"lowPriorityFiles": [],
|
||||||
|
"rootMainFiles": [],
|
||||||
|
"pages": {
|
||||||
|
"/_app": []
|
||||||
|
},
|
||||||
|
"ampFirstPages": []
|
||||||
|
};
|
||||||
|
self.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||||
|
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||||
|
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||||
|
|
||||||
|
];
|
||||||
6
web/.next/server/middleware-manifest.json
Normal file
6
web/.next/server/middleware-manifest.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"middleware": {},
|
||||||
|
"functions": {},
|
||||||
|
"sortedMiddleware": []
|
||||||
|
}
|
||||||
1
web/.next/server/middleware-react-loadable-manifest.js
Normal file
1
web/.next/server/middleware-react-loadable-manifest.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||||
1
web/.next/server/next-font-manifest.js
Normal file
1
web/.next/server/next-font-manifest.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
||||||
1
web/.next/server/next-font-manifest.json
Normal file
1
web/.next/server/next-font-manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
||||||
1
web/.next/server/pages-manifest.json
Normal file
1
web/.next/server/pages-manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
||||||
1
web/.next/server/server-reference-manifest.js
Normal file
1
web/.next/server/server-reference-manifest.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
||||||
5
web/.next/server/server-reference-manifest.json
Normal file
5
web/.next/server/server-reference-manifest.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"node": {},
|
||||||
|
"edge": {},
|
||||||
|
"encryptionKey": "su0GaTomCNeS6ZeJVTDKYwMbD/s56Dn1rGzRZrrq7Qs="
|
||||||
|
}
|
||||||
1
web/.next/static/chunks/polyfills.js
Normal file
1
web/.next/static/chunks/polyfills.js
Normal file
File diff suppressed because one or more lines are too long
1
web/.next/static/development/_buildManifest.js
Normal file
1
web/.next/static/development/_buildManifest.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||||
1
web/.next/static/development/_ssgManifest.js
Normal file
1
web/.next/static/development/_ssgManifest.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||||
2
web/.next/trace
Normal file
2
web/.next/trace
Normal file
File diff suppressed because one or more lines are too long
1
web/.next/types/package.json
Normal file
1
web/.next/types/package.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"type": "module"}
|
||||||
149
web/app/(auth)/login/page.tsx
Normal file
149
web/app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { AuthLayout } from '@/components/layouts/AuthLayout'
|
||||||
|
import { Input } from '@/components/common/Input'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { Checkbox } from '@/components/common/Checkbox'
|
||||||
|
import { Link } from '@/components/common/Link'
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, isLoading, error } = useAuth()
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
rememberMe: false,
|
||||||
|
})
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
errors.email = 'Email is required'
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||||
|
errors.email = 'Email is invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
errors.password = 'Password is required'
|
||||||
|
} else if (formData.password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors)
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await login({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Clear error for this field
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev }
|
||||||
|
delete newErrors[name]
|
||||||
|
return newErrors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Welcome back"
|
||||||
|
subtitle="Sign in to your account to continue"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="bg-error-50 border border-error-200 text-error-800 px-4 py-3 rounded-md"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
{error.message || 'Login failed. Please check your credentials and try again.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label="Email address"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.email}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.password}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Checkbox
|
||||||
|
name="rememberMe"
|
||||||
|
label="Remember me"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/auth/reset-password"
|
||||||
|
variant="primary"
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link href="/auth/signup" variant="primary">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
web/app/(auth)/reset-password/confirm/page.tsx
Normal file
195
web/app/(auth)/reset-password/confirm/page.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
import { AuthLayout } from '@/components/layouts/AuthLayout'
|
||||||
|
import { Input } from '@/components/common/Input'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { Link } from '@/components/common/Link'
|
||||||
|
import { useApi } from '@/lib/hooks/useApi'
|
||||||
|
|
||||||
|
export default function ResetPasswordConfirmPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const { execute, isLoading, error } = useApi()
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tokenParam = searchParams.get('token')
|
||||||
|
if (tokenParam) {
|
||||||
|
setToken(tokenParam)
|
||||||
|
} else {
|
||||||
|
// Redirect to reset password page if no token
|
||||||
|
router.push('/auth/reset-password')
|
||||||
|
}
|
||||||
|
}, [searchParams, router])
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
errors.password = 'Password is required'
|
||||||
|
} else if (formData.password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters'
|
||||||
|
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
|
||||||
|
errors.password = 'Password must contain uppercase, lowercase, and number'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Please confirm your password'
|
||||||
|
} else if (formData.password !== formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Passwords do not match'
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors)
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await execute({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/reset-password/confirm',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
password: formData.password,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setSuccess(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Clear error for this field
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev }
|
||||||
|
delete newErrors[name]
|
||||||
|
return newErrors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Password reset successful"
|
||||||
|
subtitle="Your password has been updated"
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 bg-success-100 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-success-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Your password has been successfully reset. You can now sign in with your new password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Link href="/auth/login">
|
||||||
|
<Button variant="primary" size="lg" fullWidth>
|
||||||
|
Go to login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Set new password"
|
||||||
|
subtitle="Enter your new password below"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="bg-error-50 border border-error-200 text-error-800 px-4 py-3 rounded-md"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
{error.message || 'Failed to reset password. The link may have expired.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="New password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.password}
|
||||||
|
helperText="At least 8 characters with uppercase, lowercase, and number"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm new password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.confirmPassword}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Remember your password?{' '}
|
||||||
|
<Link href="/auth/login" variant="primary">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
web/app/(auth)/reset-password/page.tsx
Normal file
144
web/app/(auth)/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { AuthLayout } from '@/components/layouts/AuthLayout'
|
||||||
|
import { Input } from '@/components/common/Input'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { Link } from '@/components/common/Link'
|
||||||
|
import { useApi } from '@/lib/hooks/useApi'
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const { execute, isLoading, error } = useApi()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [emailError, setEmailError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const validateEmail = () => {
|
||||||
|
if (!email) {
|
||||||
|
setEmailError('Email is required')
|
||||||
|
return false
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(email)) {
|
||||||
|
setEmailError('Email is invalid')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setEmailError('')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateEmail()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await execute({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/reset-password/request',
|
||||||
|
data: { email },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setSuccess(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Check your email"
|
||||||
|
subtitle="We've sent you a password reset link"
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="w-16 h-16 bg-success-100 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-success-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
We've sent a password reset link to{' '}
|
||||||
|
<strong className="text-gray-900 dark:text-white">{email}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Please check your email and click the link to reset your password.
|
||||||
|
The link will expire in 24 hours.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Link href="/auth/login" variant="primary">
|
||||||
|
<Button variant="ghost" fullWidth>
|
||||||
|
Back to login
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Reset your password"
|
||||||
|
subtitle="Enter your email address and we'll send you a reset link"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="bg-error-50 border border-error-200 text-error-800 px-4 py-3 rounded-md"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
{error.message || 'Failed to send reset email. Please try again.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label="Email address"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
if (emailError) setEmailError('')
|
||||||
|
}}
|
||||||
|
error={emailError}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Send reset link
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Remember your password?{' '}
|
||||||
|
<Link href="/auth/login" variant="primary">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
web/app/(auth)/signup/page.tsx
Normal file
194
web/app/(auth)/signup/page.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { AuthLayout } from '@/components/layouts/AuthLayout'
|
||||||
|
import { Input } from '@/components/common/Input'
|
||||||
|
import { Button } from '@/components/common/Button'
|
||||||
|
import { Checkbox } from '@/components/common/Checkbox'
|
||||||
|
import { Link } from '@/components/common/Link'
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth'
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const { signup, isLoading, error } = useAuth()
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
display_name: '',
|
||||||
|
acceptTerms: false,
|
||||||
|
})
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
errors.email = 'Email is required'
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||||
|
errors.email = 'Email is invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
errors.password = 'Password is required'
|
||||||
|
} else if (formData.password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters'
|
||||||
|
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
|
||||||
|
errors.password = 'Password must contain uppercase, lowercase, and number'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Please confirm your password'
|
||||||
|
} else if (formData.password !== formData.confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Passwords do not match'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.acceptTerms) {
|
||||||
|
errors.acceptTerms = 'You must accept the terms and conditions'
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors)
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await signup({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
display_name: formData.display_name || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Clear error for this field
|
||||||
|
if (formErrors[name]) {
|
||||||
|
setFormErrors((prev) => {
|
||||||
|
const newErrors = { ...prev }
|
||||||
|
delete newErrors[name]
|
||||||
|
return newErrors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
title="Create your account"
|
||||||
|
subtitle="Join the MoreThanADiagnosis community"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="bg-error-50 border border-error-200 text-error-800 px-4 py-3 rounded-md"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
{error.message || 'Signup failed. Please try again.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
label="Email address"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.email}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="display_name"
|
||||||
|
label="Display name (optional)"
|
||||||
|
placeholder="How should we call you?"
|
||||||
|
value={formData.display_name}
|
||||||
|
onChange={handleChange}
|
||||||
|
helperText="This will be your public display name"
|
||||||
|
fullWidth
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.password}
|
||||||
|
helperText="At least 8 characters with uppercase, lowercase, and number"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
label="Confirm password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.confirmPassword}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
name="acceptTerms"
|
||||||
|
checked={formData.acceptTerms}
|
||||||
|
onChange={handleChange}
|
||||||
|
error={formErrors.acceptTerms}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
I accept the{' '}
|
||||||
|
<Link href="/legal/terms" external variant="primary">
|
||||||
|
Terms of Service
|
||||||
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link href="/legal/privacy" external variant="primary">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link href="/auth/login" variant="primary">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
web/app/globals.css
Normal file
91
web/app/globals.css
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Light mode colors */
|
||||||
|
--color-primary: #3B82F6;
|
||||||
|
--color-secondary: #8B5CF6;
|
||||||
|
--color-error: #EF4444;
|
||||||
|
--color-success: #10B981;
|
||||||
|
--color-warning: #F59E0B;
|
||||||
|
|
||||||
|
--color-bg: #FFFFFF;
|
||||||
|
--color-bg-secondary: #F9FAFB;
|
||||||
|
--color-text: #111827;
|
||||||
|
--color-text-secondary: #6B7280;
|
||||||
|
--color-border: #D1D5DB;
|
||||||
|
|
||||||
|
/* Spacing base unit */
|
||||||
|
--spacing-unit: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark mode colors */
|
||||||
|
--color-bg: #1F2937;
|
||||||
|
--color-bg-secondary: #111827;
|
||||||
|
--color-text: #F9FAFB;
|
||||||
|
--color-text-secondary: #D1D5DB;
|
||||||
|
--color-border: #4B5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles for keyboard navigation */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
:root {
|
||||||
|
--color-border: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-border: #FFFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only class */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
22
web/app/layout.tsx
Normal file
22
web/app/layout.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Inter } from 'next/font/google'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'MoreThanADiagnosis',
|
||||||
|
description: 'Community platform for health advocacy and support',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
web/app/page.tsx
Normal file
14
web/app/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
MoreThanADiagnosis
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Web frontend implementation in progress...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
web/components/common/Avatar.tsx
Normal file
72
web/components/common/Avatar.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
|
||||||
|
export interface AvatarProps {
|
||||||
|
src?: string
|
||||||
|
alt: string
|
||||||
|
size?: AvatarSize
|
||||||
|
fallbackText?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<AvatarSize, string> = {
|
||||||
|
xs: 'w-6 h-6 text-xs',
|
||||||
|
sm: 'w-8 h-8 text-sm',
|
||||||
|
md: 'w-10 h-10 text-base',
|
||||||
|
lg: 'w-12 h-12 text-lg',
|
||||||
|
xl: 'w-16 h-16 text-xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
size = 'md',
|
||||||
|
fallbackText,
|
||||||
|
className = '',
|
||||||
|
}: AvatarProps) => {
|
||||||
|
const [imageError, setImageError] = React.useState(false)
|
||||||
|
|
||||||
|
const initials = React.useMemo(() => {
|
||||||
|
if (fallbackText) {
|
||||||
|
return fallbackText
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}
|
||||||
|
return alt
|
||||||
|
.split(' ')
|
||||||
|
.map((word) => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
}, [fallbackText, alt])
|
||||||
|
|
||||||
|
const showFallback = !src || imageError
|
||||||
|
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center rounded-full overflow-hidden'
|
||||||
|
const fallbackStyles = 'bg-gradient-to-br from-primary-400 to-secondary-500 text-white font-semibold'
|
||||||
|
|
||||||
|
const combinedClassName = `${baseStyles} ${sizeStyles[size]} ${showFallback ? fallbackStyles : ''} ${className}`.trim()
|
||||||
|
|
||||||
|
if (showFallback) {
|
||||||
|
return (
|
||||||
|
<div className={combinedClassName} role="img" aria-label={alt}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={combinedClassName}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
web/components/common/Badge.tsx
Normal file
45
web/components/common/Badge.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type BadgeVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'neutral'
|
||||||
|
export type BadgeSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
variant?: BadgeVariant
|
||||||
|
size?: BadgeSize
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<BadgeVariant, string> = {
|
||||||
|
primary: 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200',
|
||||||
|
secondary: 'bg-secondary-100 text-secondary-800 dark:bg-secondary-900 dark:text-secondary-200',
|
||||||
|
success: 'bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200',
|
||||||
|
warning: 'bg-warning-100 text-warning-800 dark:bg-warning-900 dark:text-warning-200',
|
||||||
|
error: 'bg-error-100 text-error-800 dark:bg-error-900 dark:text-error-200',
|
||||||
|
neutral: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<BadgeSize, string> = {
|
||||||
|
sm: 'px-2 py-0.5 text-xs',
|
||||||
|
md: 'px-2.5 py-1 text-sm',
|
||||||
|
lg: 'px-3 py-1.5 text-base',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge = ({
|
||||||
|
variant = 'neutral',
|
||||||
|
size = 'md',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: BadgeProps) => {
|
||||||
|
const baseStyles = 'inline-flex items-center font-medium rounded-full'
|
||||||
|
|
||||||
|
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={combinedClassName}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
web/components/common/Button.tsx
Normal file
86
web/components/common/Button.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||||
|
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant
|
||||||
|
size?: ButtonSize
|
||||||
|
isLoading?: boolean
|
||||||
|
fullWidth?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<ButtonVariant, string> = {
|
||||||
|
primary: 'bg-primary-500 hover:bg-primary-600 active:bg-primary-700 text-white shadow-sm disabled:bg-primary-300',
|
||||||
|
secondary: 'bg-secondary-500 hover:bg-secondary-600 active:bg-secondary-700 text-white shadow-sm disabled:bg-secondary-300',
|
||||||
|
ghost: 'bg-transparent hover:bg-gray-100 active:bg-gray-200 text-gray-700 dark:hover:bg-gray-800 dark:active:bg-gray-700 dark:text-gray-200',
|
||||||
|
danger: 'bg-error-500 hover:bg-error-600 active:bg-error-700 text-white shadow-sm disabled:bg-error-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<ButtonSize, string> = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-base',
|
||||||
|
lg: 'px-6 py-3 text-lg',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
fullWidth = false,
|
||||||
|
className = '',
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-md transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60'
|
||||||
|
|
||||||
|
const widthStyles = fullWidth ? 'w-full' : ''
|
||||||
|
|
||||||
|
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${widthStyles} ${className}`.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={combinedClassName}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
aria-busy={isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button.displayName = 'Button'
|
||||||
75
web/components/common/Card.tsx
Normal file
75
web/components/common/Card.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type CardVariant = 'elevated' | 'outlined' | 'flat'
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
variant?: CardVariant
|
||||||
|
className?: string
|
||||||
|
padding?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardHeaderProps {
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardBodyProps {
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardFooterProps {
|
||||||
|
className?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<CardVariant, string> = {
|
||||||
|
elevated: 'bg-white dark:bg-gray-800 shadow-md',
|
||||||
|
outlined: 'bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700',
|
||||||
|
flat: 'bg-gray-50 dark:bg-gray-900',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card = ({
|
||||||
|
variant = 'elevated',
|
||||||
|
className = '',
|
||||||
|
padding = true,
|
||||||
|
children,
|
||||||
|
}: CardProps) => {
|
||||||
|
const baseStyles = 'rounded-lg'
|
||||||
|
const paddingStyles = padding ? 'p-6' : ''
|
||||||
|
|
||||||
|
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${paddingStyles} ${className}`.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={combinedClassName}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardHeader = ({ className = '', children }: CardHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${className}`.trim()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardBody = ({ className = '', children }: CardBodyProps) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardFooter = ({ className = '', children }: CardFooterProps) => {
|
||||||
|
return (
|
||||||
|
<div className={`mt-4 ${className}`.trim()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
web/components/common/Checkbox.tsx
Normal file
50
web/components/common/Checkbox.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ label, error, className = '', id, ...props }, ref) => {
|
||||||
|
const checkboxId = id || `checkbox-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const errorId = `${checkboxId}-error`
|
||||||
|
|
||||||
|
const hasError = Boolean(error)
|
||||||
|
|
||||||
|
const baseStyles = 'h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900'
|
||||||
|
const errorStyles = hasError ? 'border-error-500' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
id={checkboxId}
|
||||||
|
className={`${baseStyles} ${errorStyles} ${className}`.trim()}
|
||||||
|
aria-invalid={hasError}
|
||||||
|
aria-describedby={hasError ? errorId : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{label && (
|
||||||
|
<div className="ml-3 text-sm">
|
||||||
|
<label htmlFor={checkboxId} className="text-gray-700 dark:text-gray-200">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p className="text-error-500 mt-1" id={errorId} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Checkbox.displayName = 'Checkbox'
|
||||||
66
web/components/common/Footer.tsx
Normal file
66
web/components/common/Footer.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from './Link'
|
||||||
|
|
||||||
|
export const Footer = () => {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
const footerLinks = {
|
||||||
|
'About': [
|
||||||
|
{ label: 'Mission', href: '/about/mission' },
|
||||||
|
{ label: 'Team', href: '/about/team' },
|
||||||
|
{ label: 'Contact', href: '/about/contact' },
|
||||||
|
],
|
||||||
|
'Community': [
|
||||||
|
{ label: 'Blog', href: '/blog' },
|
||||||
|
{ label: 'Forum', href: '/forum' },
|
||||||
|
{ label: 'Podcast', href: '/podcast' },
|
||||||
|
],
|
||||||
|
'Resources': [
|
||||||
|
{ label: 'Knowledge Base', href: '/resources' },
|
||||||
|
{ label: 'Support', href: '/support' },
|
||||||
|
{ label: 'FAQ', href: '/faq' },
|
||||||
|
],
|
||||||
|
'Legal': [
|
||||||
|
{ label: 'Privacy Policy', href: '/legal/privacy' },
|
||||||
|
{ label: 'Terms of Service', href: '/legal/terms' },
|
||||||
|
{ label: 'Code of Conduct', href: '/legal/conduct' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
|
{Object.entries(footerLinks).map(([category, links]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider mb-4">
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{links.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
variant="neutral"
|
||||||
|
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white text-sm no-underline hover:underline"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
© {currentYear} MoreThanADiagnosis. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
web/components/common/FormField.tsx
Normal file
58
web/components/common/FormField.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface FormFieldProps {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helperText?: string
|
||||||
|
required?: boolean
|
||||||
|
htmlFor?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormField = ({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
required,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: FormFieldProps) => {
|
||||||
|
const errorId = htmlFor ? `${htmlFor}-error` : undefined
|
||||||
|
const helperId = htmlFor ? `${htmlFor}-helper` : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`mb-4 ${className}`.trim()}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="text-error-500 ml-1" aria-label="required">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-error-500" id={errorId} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400" id={helperId}>
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
web/components/common/Header.tsx
Normal file
192
web/components/common/Header.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from './Link'
|
||||||
|
import { Button } from './Button'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
isAuthenticated?: boolean
|
||||||
|
userDisplayName?: string
|
||||||
|
onLogin?: () => void
|
||||||
|
onLogout?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header = ({
|
||||||
|
isAuthenticated = false,
|
||||||
|
userDisplayName,
|
||||||
|
onLogin,
|
||||||
|
onLogout,
|
||||||
|
}: HeaderProps) => {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: 'Blog', href: '/blog' },
|
||||||
|
{ label: 'Forum', href: '/forum' },
|
||||||
|
{ label: 'Podcast', href: '/podcast' },
|
||||||
|
{ label: 'Resources', href: '/resources' },
|
||||||
|
{ label: 'Merch', href: '/merch' },
|
||||||
|
{ label: 'Tribute', href: '/tribute' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" aria-label="Main navigation">
|
||||||
|
<div className="flex justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link href="/" variant="neutral" className="no-underline">
|
||||||
|
<span className="text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
MoreThanADiagnosis
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex md:items-center md:space-x-6">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
variant="neutral"
|
||||||
|
className="text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white no-underline hover:underline"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth Section */}
|
||||||
|
<div className="hidden md:flex md:items-center md:space-x-4">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link href="/dashboard" variant="neutral" className="no-underline">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
alt={userDisplayName || 'User'}
|
||||||
|
fallbackText={userDisplayName}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700 dark:text-gray-300">
|
||||||
|
{userDisplayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onLogin}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
<Link href="/auth/signup">
|
||||||
|
<Button variant="primary" size="sm">
|
||||||
|
Sign Up
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<div className="flex items-center md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white p-2"
|
||||||
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
|
aria-expanded={mobileMenuOpen}
|
||||||
|
aria-label="Toggle mobile menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
{mobileMenuOpen ? (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu */}
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<div className="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
variant="neutral"
|
||||||
|
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded-md no-underline"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-2 mt-2">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
variant="neutral"
|
||||||
|
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded-md no-underline"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded-md"
|
||||||
|
onClick={() => {
|
||||||
|
onLogout?.()
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded-md"
|
||||||
|
onClick={() => {
|
||||||
|
onLogin?.()
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/auth/signup"
|
||||||
|
variant="neutral"
|
||||||
|
className="block px-4 py-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 rounded-md no-underline"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
web/components/common/Input.tsx
Normal file
124
web/components/common/Input.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
|
||||||
|
|
||||||
|
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'size'> {
|
||||||
|
type?: InputType
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helperText?: string
|
||||||
|
fullWidth?: boolean
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightIcon?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
type = 'text',
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
fullWidth = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
className = '',
|
||||||
|
id,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const errorId = `${inputId}-error`
|
||||||
|
const helperId = `${inputId}-helper`
|
||||||
|
|
||||||
|
const hasError = Boolean(error)
|
||||||
|
|
||||||
|
const baseInputStyles = 'block w-full rounded-md border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-0'
|
||||||
|
const normalStyles = 'border-gray-300 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white'
|
||||||
|
const errorStyles = 'border-error-500 focus:border-error-500 focus:ring-error-500'
|
||||||
|
const disabledStyles = 'disabled:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:disabled:bg-gray-900'
|
||||||
|
const paddingStyles = leftIcon || rightIcon ? 'px-10 py-2' : 'px-4 py-2'
|
||||||
|
|
||||||
|
const inputClassName = `${baseInputStyles} ${hasError ? errorStyles : normalStyles} ${disabledStyles} ${paddingStyles} ${className}`.trim()
|
||||||
|
const containerWidth = fullWidth ? 'w-full' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerWidth}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error-500 ml-1" aria-label="required">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{leftIcon && (
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
|
||||||
|
{leftIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
id={inputId}
|
||||||
|
className={inputClassName}
|
||||||
|
aria-invalid={hasError}
|
||||||
|
aria-describedby={
|
||||||
|
hasError ? errorId : helperText ? helperId : undefined
|
||||||
|
}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rightIcon && (
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400">
|
||||||
|
{rightIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasError && (
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-error-500"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-error-500" id={errorId} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400" id={helperId}>
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Input.displayName = 'Input'
|
||||||
49
web/components/common/Link.tsx
Normal file
49
web/components/common/Link.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import NextLink from 'next/link'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface LinkProps {
|
||||||
|
href: string
|
||||||
|
children: React.ReactNode
|
||||||
|
variant?: 'primary' | 'secondary' | 'neutral'
|
||||||
|
external?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary: 'text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300',
|
||||||
|
secondary: 'text-secondary-600 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-300',
|
||||||
|
neutral: 'text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Link = ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = 'primary',
|
||||||
|
external = false,
|
||||||
|
className = '',
|
||||||
|
}: LinkProps) => {
|
||||||
|
const baseStyles = 'underline decoration-1 underline-offset-2 hover:decoration-2 transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 rounded-sm'
|
||||||
|
|
||||||
|
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className}`.trim()
|
||||||
|
|
||||||
|
if (external) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className={combinedClassName}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextLink href={href} className={combinedClassName}>
|
||||||
|
{children}
|
||||||
|
</NextLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
137
web/components/common/Modal.tsx
Normal file
137
web/components/common/Modal.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
closeOnOverlayClick?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeStyles: Record<string, string> = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
size = 'md',
|
||||||
|
closeOnOverlayClick = true,
|
||||||
|
closeOnEscape = true,
|
||||||
|
showCloseButton = true,
|
||||||
|
}: ModalProps) => {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const previousActiveElement = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !closeOnEscape) return
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape)
|
||||||
|
}, [isOpen, closeOnEscape, onClose])
|
||||||
|
|
||||||
|
// Handle focus trap and return focus
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
previousActiveElement.current = document.activeElement as HTMLElement
|
||||||
|
modalRef.current?.focus()
|
||||||
|
|
||||||
|
// Prevent body scroll
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
// Restore body scroll
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
|
||||||
|
// Return focus to previous element
|
||||||
|
previousActiveElement.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (closeOnOverlayClick && e.target === e.currentTarget) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 backdrop-blur-sm"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? 'modal-title' : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full ${sizeStyles[size]} max-h-[90vh] overflow-y-auto`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{(title || showCloseButton) && (
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{title && (
|
||||||
|
<h2
|
||||||
|
id="modal-title"
|
||||||
|
className="text-xl font-semibold text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{showCloseButton && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return createPortal(modal, document.body)
|
||||||
|
}
|
||||||
107
web/components/common/Select.tsx
Normal file
107
web/components/common/Select.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helperText?: string
|
||||||
|
options: SelectOption[]
|
||||||
|
placeholder?: string
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
fullWidth = false,
|
||||||
|
className = '',
|
||||||
|
id,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const errorId = `${selectId}-error`
|
||||||
|
const helperId = `${selectId}-helper`
|
||||||
|
|
||||||
|
const hasError = Boolean(error)
|
||||||
|
|
||||||
|
const baseStyles = 'block w-full rounded-md border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-0 px-4 py-2'
|
||||||
|
const normalStyles = 'border-gray-300 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white'
|
||||||
|
const errorStyles = 'border-error-500 focus:border-error-500 focus:ring-error-500'
|
||||||
|
const disabledStyles = 'disabled:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:disabled:bg-gray-900'
|
||||||
|
|
||||||
|
const selectClassName = `${baseStyles} ${hasError ? errorStyles : normalStyles} ${disabledStyles} ${className}`.trim()
|
||||||
|
const containerWidth = fullWidth ? 'w-full' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerWidth}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={selectId}
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error-500 ml-1" aria-label="required">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
id={selectId}
|
||||||
|
className={selectClassName}
|
||||||
|
aria-invalid={hasError}
|
||||||
|
aria-describedby={
|
||||||
|
hasError ? errorId : helperText ? helperId : undefined
|
||||||
|
}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder && (
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-error-500" id={errorId} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400" id={helperId}>
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Select.displayName = 'Select'
|
||||||
84
web/components/common/Textarea.tsx
Normal file
84
web/components/common/Textarea.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string
|
||||||
|
error?: string
|
||||||
|
helperText?: string
|
||||||
|
fullWidth?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
fullWidth = false,
|
||||||
|
className = '',
|
||||||
|
id,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
rows = 4,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
const errorId = `${textareaId}-error`
|
||||||
|
const helperId = `${textareaId}-helper`
|
||||||
|
|
||||||
|
const hasError = Boolean(error)
|
||||||
|
|
||||||
|
const baseStyles = 'block w-full rounded-md border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-0 px-4 py-2'
|
||||||
|
const normalStyles = 'border-gray-300 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white'
|
||||||
|
const errorStyles = 'border-error-500 focus:border-error-500 focus:ring-error-500'
|
||||||
|
const disabledStyles = 'disabled:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:disabled:bg-gray-900'
|
||||||
|
|
||||||
|
const textareaClassName = `${baseStyles} ${hasError ? errorStyles : normalStyles} ${disabledStyles} ${className}`.trim()
|
||||||
|
const containerWidth = fullWidth ? 'w-full' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerWidth}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={textareaId}
|
||||||
|
className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error-500 ml-1" aria-label="required">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
id={textareaId}
|
||||||
|
className={textareaClassName}
|
||||||
|
aria-invalid={hasError}
|
||||||
|
aria-describedby={
|
||||||
|
hasError ? errorId : helperText ? helperId : undefined
|
||||||
|
}
|
||||||
|
required={required}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={rows}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-error-500" id={errorId} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400" id={helperId}>
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea'
|
||||||
62
web/components/layouts/AuthLayout.tsx
Normal file
62
web/components/layouts/AuthLayout.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from '../common/Link'
|
||||||
|
|
||||||
|
export interface AuthLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthLayout = ({ children, title, subtitle }: AuthLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Simple header with logo */}
|
||||||
|
<header className="py-6">
|
||||||
|
<div className="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<Link href="/" variant="neutral" className="no-underline">
|
||||||
|
<h1 className="text-2xl font-bold text-primary-600 dark:text-primary-400 text-center">
|
||||||
|
MoreThanADiagnosis
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
{/* Title and subtitle */}
|
||||||
|
{(title || subtitle) && (
|
||||||
|
<div className="text-center">
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form container */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 py-8 px-6 shadow-lg rounded-lg">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Simple footer */}
|
||||||
|
<footer className="py-6">
|
||||||
|
<div className="max-w-md mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
© {new Date().getFullYear()} MoreThanADiagnosis. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
web/components/layouts/DashboardLayout.tsx
Normal file
157
web/components/layouts/DashboardLayout.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Header } from '../common/Header'
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth'
|
||||||
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
|
import { Link } from '../common/Link'
|
||||||
|
|
||||||
|
export interface DashboardLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardLayout = ({ children }: DashboardLayoutProps) => {
|
||||||
|
const { user, isAuthenticated, logout } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
router.push('/auth/login')
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router])
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
router.push('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarItems = [
|
||||||
|
{ label: 'Overview', href: '/dashboard', icon: '📊' },
|
||||||
|
{ label: 'Profile', href: '/dashboard/profile', icon: '👤' },
|
||||||
|
{ label: 'Preferences', href: '/dashboard/preferences', icon: '⚙️' },
|
||||||
|
{ label: 'Security', href: '/dashboard/security', icon: '🔒' },
|
||||||
|
{ label: 'Privacy', href: '/dashboard/privacy', icon: '🛡️' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null // or a loading spinner
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
userDisplayName={user?.display_name || user?.email}
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
{/* Sidebar for desktop */}
|
||||||
|
<aside className="hidden md:flex md:flex-shrink-0">
|
||||||
|
<div className="w-64 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<nav className="flex-1 px-4 py-6 space-y-1" aria-label="Dashboard navigation">
|
||||||
|
{sidebarItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
variant="neutral"
|
||||||
|
className={`
|
||||||
|
flex items-center px-4 py-2 text-sm font-medium rounded-md no-underline
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-100 text-primary-900 dark:bg-primary-900 dark:text-primary-100'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className="mr-3 text-xl" aria-hidden="true">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile sidebar */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 md:hidden">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-600 bg-opacity-75"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-y-0 left-0 flex flex-col w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Dashboard
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 px-4 py-6 space-y-1">
|
||||||
|
{sidebarItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
variant="neutral"
|
||||||
|
className={`
|
||||||
|
flex items-center px-4 py-2 text-sm font-medium rounded-md no-underline
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-100 text-primary-900 dark:bg-primary-900 dark:text-primary-100'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<span className="mr-3 text-xl" aria-hidden="true">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
{/* Mobile sidebar toggle */}
|
||||||
|
<div className="md:hidden px-4 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
web/components/layouts/MainLayout.tsx
Normal file
37
web/components/layouts/MainLayout.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Header } from '../common/Header'
|
||||||
|
import { Footer } from '../common/Footer'
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export interface MainLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainLayout = ({ children }: MainLayoutProps) => {
|
||||||
|
const { user, isAuthenticated, logout } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
router.push('/auth/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||||
|
<Header
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
userDisplayName={user?.display_name || user?.email}
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
web/lib/api.ts
Normal file
77
web/lib/api.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000/api/v1'
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000, // 30 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor to add auth token
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// Get token from localStorage (will be replaced with proper auth context)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor to handle token refresh
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
// If 401 and we haven't retried yet, try to refresh token
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { access_token } = response.data
|
||||||
|
localStorage.setItem('access_token', access_token)
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient(originalRequest)
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed, clear tokens and redirect to login
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/auth/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(refreshError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
73
web/lib/hooks/useApi.ts
Normal file
73
web/lib/hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { AxiosError, AxiosRequestConfig } from 'axios'
|
||||||
|
import { apiClient } from '../api'
|
||||||
|
|
||||||
|
export interface UseApiOptions {
|
||||||
|
onSuccess?: (data: any) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseApiReturn<T> {
|
||||||
|
data: T | null
|
||||||
|
error: Error | null
|
||||||
|
isLoading: boolean
|
||||||
|
execute: (config: AxiosRequestConfig) => Promise<T | null>
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi<T = any>(options?: UseApiOptions): UseApiReturn<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const execute = useCallback(
|
||||||
|
async (config: AxiosRequestConfig): Promise<T | null> => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.request<T>(config)
|
||||||
|
setData(response.data)
|
||||||
|
|
||||||
|
if (options?.onSuccess) {
|
||||||
|
options.onSuccess(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as AxiosError
|
||||||
|
const errorMessage = error.response?.data
|
||||||
|
? JSON.stringify(error.response.data)
|
||||||
|
: error.message
|
||||||
|
|
||||||
|
const finalError = new Error(errorMessage)
|
||||||
|
setError(finalError)
|
||||||
|
|
||||||
|
if (options?.onError) {
|
||||||
|
options.onError(finalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[options]
|
||||||
|
)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setData(null)
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
execute,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
93
web/lib/hooks/useAuth.ts
Normal file
93
web/lib/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuthStore } from '../store/authStore'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignupData {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
is_verified: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user, isAuthenticated, setAuth, clearAuth, updateUser } = useAuthStore()
|
||||||
|
const loginApi = useApi<AuthResponse>()
|
||||||
|
const signupApi = useApi<AuthResponse>()
|
||||||
|
|
||||||
|
const login = async (credentials: LoginCredentials) => {
|
||||||
|
const data = await loginApi.execute({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/login',
|
||||||
|
data: credentials,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setAuth(data.user, data.access_token, data.refresh_token)
|
||||||
|
router.push('/dashboard')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const signup = async (signupData: SignupData) => {
|
||||||
|
const data = await signupApi.execute({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/signup',
|
||||||
|
data: signupData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setAuth(data.user, data.access_token, data.refresh_token)
|
||||||
|
router.push('/dashboard')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
// Call logout endpoint
|
||||||
|
await loginApi.execute({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/auth/logout',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
} finally {
|
||||||
|
// Clear auth state regardless of API result
|
||||||
|
clearAuth()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
signup,
|
||||||
|
logout,
|
||||||
|
updateUser,
|
||||||
|
isLoading: loginApi.isLoading || signupApi.isLoading,
|
||||||
|
error: loginApi.error || signupApi.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
63
web/lib/hooks/useLocalStorage.ts
Normal file
63
web/lib/hooks/useLocalStorage.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
|
// State to store our value
|
||||||
|
// Pass initial state function to useState so logic is only executed once
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get from local storage by key
|
||||||
|
const item = window.localStorage.getItem(key)
|
||||||
|
// Parse stored json or if none return initialValue
|
||||||
|
return item ? JSON.parse(item) : initialValue
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading localStorage key "${key}":`, error)
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return a wrapped version of useState's setter function that
|
||||||
|
// persists the new value to localStorage.
|
||||||
|
const setValue = (value: T | ((val: T) => T)) => {
|
||||||
|
try {
|
||||||
|
// Allow value to be a function so we have same API as useState
|
||||||
|
const valueToStore =
|
||||||
|
value instanceof Function ? value(storedValue) : value
|
||||||
|
|
||||||
|
// Save state
|
||||||
|
setStoredValue(valueToStore)
|
||||||
|
|
||||||
|
// Save to local storage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error setting localStorage key "${key}":`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for changes to this key in other tabs/windows
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === key && e.newValue !== null) {
|
||||||
|
try {
|
||||||
|
setStoredValue(JSON.parse(e.newValue))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing storage event for key "${key}":`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange)
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange)
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
return [storedValue, setValue] as const
|
||||||
|
}
|
||||||
75
web/lib/store/authStore.ts
Normal file
75
web/lib/store/authStore.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
display_name?: string
|
||||||
|
is_verified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
accessToken: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setAuth: (user: User, accessToken: string, refreshToken: string) => void
|
||||||
|
updateUser: (user: Partial<User>) => void
|
||||||
|
clearAuth: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setAuth: (user, accessToken, refreshToken) => {
|
||||||
|
// Store tokens in localStorage for API client
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('access_token', accessToken)
|
||||||
|
localStorage.setItem('refresh_token', refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
isAuthenticated: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
user: state.user ? { ...state.user, ...updates } : null,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAuth: () => {
|
||||||
|
// Clear tokens from localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
// Don't persist tokens in zustand, only in localStorage
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
5
web/next-env.d.ts
vendored
Normal file
5
web/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
6332
web/package-lock.json
generated
Normal file
6332
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
110
web/tailwind.config.ts
Normal file
110
web/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#3B82F6',
|
||||||
|
50: '#EFF6FF',
|
||||||
|
100: '#DBEAFE',
|
||||||
|
200: '#BFDBFE',
|
||||||
|
300: '#93C5FD',
|
||||||
|
400: '#60A5FA',
|
||||||
|
500: '#3B82F6',
|
||||||
|
600: '#2563EB',
|
||||||
|
700: '#1D4ED8',
|
||||||
|
800: '#1E40AF',
|
||||||
|
900: '#1E3A8A',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: '#8B5CF6',
|
||||||
|
50: '#F5F3FF',
|
||||||
|
100: '#EDE9FE',
|
||||||
|
200: '#DDD6FE',
|
||||||
|
300: '#C4B5FD',
|
||||||
|
400: '#A78BFA',
|
||||||
|
500: '#8B5CF6',
|
||||||
|
600: '#7C3AED',
|
||||||
|
700: '#6D28D9',
|
||||||
|
800: '#5B21B6',
|
||||||
|
900: '#4C1D95',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
DEFAULT: '#EF4444',
|
||||||
|
50: '#FEF2F2',
|
||||||
|
100: '#FEE2E2',
|
||||||
|
200: '#FECACA',
|
||||||
|
300: '#FCA5A5',
|
||||||
|
400: '#F87171',
|
||||||
|
500: '#EF4444',
|
||||||
|
600: '#DC2626',
|
||||||
|
700: '#B91C1C',
|
||||||
|
800: '#991B1B',
|
||||||
|
900: '#7F1D1D',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: '#10B981',
|
||||||
|
50: '#ECFDF5',
|
||||||
|
100: '#D1FAE5',
|
||||||
|
200: '#A7F3D0',
|
||||||
|
300: '#6EE7B7',
|
||||||
|
400: '#34D399',
|
||||||
|
500: '#10B981',
|
||||||
|
600: '#059669',
|
||||||
|
700: '#047857',
|
||||||
|
800: '#065F46',
|
||||||
|
900: '#064E3B',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: '#F59E0B',
|
||||||
|
50: '#FFFBEB',
|
||||||
|
100: '#FEF3C7',
|
||||||
|
200: '#FDE68A',
|
||||||
|
300: '#FCD34D',
|
||||||
|
400: '#FBBF24',
|
||||||
|
500: '#F59E0B',
|
||||||
|
600: '#D97706',
|
||||||
|
700: '#B45309',
|
||||||
|
800: '#92400E',
|
||||||
|
900: '#78350F',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: ['12px', { lineHeight: '1.5' }],
|
||||||
|
sm: ['14px', { lineHeight: '1.5' }],
|
||||||
|
base: ['16px', { lineHeight: '1.5' }],
|
||||||
|
lg: ['18px', { lineHeight: '1.5' }],
|
||||||
|
xl: ['20px', { lineHeight: '1.5' }],
|
||||||
|
'2xl': ['24px', { lineHeight: '1.25' }],
|
||||||
|
'3xl': ['32px', { lineHeight: '1.25' }],
|
||||||
|
'4xl': ['48px', { lineHeight: '1.25' }],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: '4px',
|
||||||
|
DEFAULT: '8px',
|
||||||
|
md: '8px',
|
||||||
|
lg: '12px',
|
||||||
|
full: '9999px',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||||
|
DEFAULT: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
md: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||||
|
lg: '0 10px 15px rgba(0, 0, 0, 0.15)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
|
@ -2,7 +2,11 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|
@ -16,10 +20,28 @@
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"jsx": "react-jsx"
|
"jsx": "preserve",
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"incremental": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue