Merge branch 'main' of https://github.com/fullsizemalt/morethanadiagnosis-hub
This commit is contained in:
commit
e3f97d6d7b
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": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
|
@ -16,10 +20,28 @@
|
|||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"isolatedModules": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue