@lastbrain/module-auth 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +504 -0
- package/dist/api/admin/users.d.ts +36 -0
- package/dist/api/admin/users.d.ts.map +1 -0
- package/dist/api/admin/users.js +90 -0
- package/dist/api/auth/me.d.ts +17 -0
- package/dist/api/auth/me.d.ts.map +1 -0
- package/dist/api/auth/me.js +34 -0
- package/dist/api/auth/profile.d.ts +32 -0
- package/dist/api/auth/profile.d.ts.map +1 -0
- package/dist/api/auth/profile.js +108 -0
- package/dist/api/storage.d.ts +13 -0
- package/dist/api/storage.d.ts.map +1 -0
- package/dist/api/storage.js +47 -0
- package/dist/auth.build.config.d.ts.map +1 -1
- package/dist/auth.build.config.js +21 -0
- package/dist/web/admin/users.d.ts.map +1 -1
- package/dist/web/admin/users.js +87 -2
- package/dist/web/auth/dashboard.d.ts +1 -1
- package/dist/web/auth/dashboard.d.ts.map +1 -1
- package/dist/web/auth/dashboard.js +42 -2
- package/dist/web/auth/profile.d.ts.map +1 -1
- package/dist/web/auth/profile.js +152 -2
- package/dist/web/auth/reglage.d.ts.map +1 -1
- package/dist/web/auth/reglage.js +98 -2
- package/package.json +5 -4
- package/src/api/admin/users.ts +124 -0
- package/src/api/auth/me.ts +48 -0
- package/src/api/auth/profile.ts +156 -0
- package/src/api/public/signin.ts +31 -0
- package/src/api/storage.ts +63 -0
- package/src/auth.build.config.ts +137 -0
- package/src/components/Doc.tsx +310 -0
- package/src/index.ts +12 -0
- package/src/server.ts +2 -0
- package/src/web/admin/users.tsx +266 -0
- package/src/web/auth/dashboard.tsx +202 -0
- package/src/web/auth/profile.tsx +381 -0
- package/src/web/auth/reglage.tsx +304 -0
- package/src/web/public/ResetPassword.tsx +3 -0
- package/src/web/public/SignInPage.tsx +255 -0
- package/src/web/public/SignUpPage.tsx +293 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardBody,
|
|
7
|
+
CardHeader,
|
|
8
|
+
Spinner,
|
|
9
|
+
Chip,
|
|
10
|
+
Divider,
|
|
11
|
+
Avatar,
|
|
12
|
+
} from "@lastbrain/ui";
|
|
13
|
+
import { User, Mail, Calendar, Shield } from "lucide-react";
|
|
14
|
+
|
|
15
|
+
interface UserData {
|
|
16
|
+
id: string;
|
|
17
|
+
email: string;
|
|
18
|
+
created_at: string;
|
|
19
|
+
profile: {
|
|
20
|
+
first_name?: string;
|
|
21
|
+
last_name?: string;
|
|
22
|
+
avatar_url?: string;
|
|
23
|
+
bio?: string;
|
|
24
|
+
company?: string;
|
|
25
|
+
location?: string;
|
|
26
|
+
} | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function DashboardPage() {
|
|
30
|
+
const [userData, setUserData] = useState<UserData | null>(null);
|
|
31
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
fetchUserData();
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const fetchUserData = async () => {
|
|
39
|
+
try {
|
|
40
|
+
setIsLoading(true);
|
|
41
|
+
const response = await fetch("/api/auth/me");
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error("Failed to fetch user data");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = await response.json();
|
|
48
|
+
setUserData(result.data);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
setError(err instanceof Error ? err.message : "An error occurred");
|
|
51
|
+
} finally {
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (isLoading) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex justify-center items-center min-h-[400px]">
|
|
59
|
+
<Spinner size="lg" label="Loading dashboard..." />
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (error) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="pt-12">
|
|
67
|
+
<Card className="max-w-2xl mx-auto">
|
|
68
|
+
<CardBody>
|
|
69
|
+
<p className="text-danger">Error: {error}</p>
|
|
70
|
+
</CardBody>
|
|
71
|
+
</Card>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!userData) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fullName = userData.profile?.first_name && userData.profile?.last_name
|
|
81
|
+
? `${userData.profile.first_name} ${userData.profile.last_name}`
|
|
82
|
+
: "User";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="pt-12 pb-12 max-w-6xl mx-auto px-4">
|
|
86
|
+
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
|
|
87
|
+
|
|
88
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
89
|
+
{/* Profile Summary Card */}
|
|
90
|
+
<Card className="col-span-full md:col-span-1">
|
|
91
|
+
<CardHeader className="flex gap-3">
|
|
92
|
+
<Avatar
|
|
93
|
+
src={userData.profile?.avatar_url}
|
|
94
|
+
icon={<User />}
|
|
95
|
+
size="lg"
|
|
96
|
+
className="flex-shrink-0"
|
|
97
|
+
/>
|
|
98
|
+
<div className="flex flex-col">
|
|
99
|
+
<p className="text-xl font-semibold">{fullName}</p>
|
|
100
|
+
<p className="text-small text-default-500">{userData.email}</p>
|
|
101
|
+
</div>
|
|
102
|
+
</CardHeader>
|
|
103
|
+
<Divider />
|
|
104
|
+
<CardBody>
|
|
105
|
+
<div className="space-y-3">
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<Mail className="w-4 h-4 text-default-400" />
|
|
108
|
+
<span className="text-small">{userData.email}</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<Calendar className="w-4 h-4 text-default-400" />
|
|
112
|
+
<span className="text-small">
|
|
113
|
+
Member since {new Date(userData.created_at).toLocaleDateString()}
|
|
114
|
+
</span>
|
|
115
|
+
</div>
|
|
116
|
+
{userData.profile?.company && (
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<Shield className="w-4 h-4 text-default-400" />
|
|
119
|
+
<span className="text-small">{userData.profile.company}</span>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</CardBody>
|
|
124
|
+
</Card>
|
|
125
|
+
|
|
126
|
+
{/* Account Status Card */}
|
|
127
|
+
<Card>
|
|
128
|
+
<CardHeader>
|
|
129
|
+
<h3 className="text-lg font-semibold">Account Status</h3>
|
|
130
|
+
</CardHeader>
|
|
131
|
+
<Divider />
|
|
132
|
+
<CardBody>
|
|
133
|
+
<div className="space-y-4">
|
|
134
|
+
<div className="flex justify-between items-center">
|
|
135
|
+
<span className="text-small">Status</span>
|
|
136
|
+
<Chip color="success" size="sm" variant="flat">
|
|
137
|
+
Active
|
|
138
|
+
</Chip>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="flex justify-between items-center">
|
|
141
|
+
<span className="text-small">Profile</span>
|
|
142
|
+
<Chip
|
|
143
|
+
color={userData.profile ? "success" : "warning"}
|
|
144
|
+
size="sm"
|
|
145
|
+
variant="flat"
|
|
146
|
+
>
|
|
147
|
+
{userData.profile ? "Complete" : "Incomplete"}
|
|
148
|
+
</Chip>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</CardBody>
|
|
152
|
+
</Card>
|
|
153
|
+
|
|
154
|
+
{/* Bio Card (if available) */}
|
|
155
|
+
{userData.profile?.bio && (
|
|
156
|
+
<Card className="col-span-full">
|
|
157
|
+
<CardHeader>
|
|
158
|
+
<h3 className="text-lg font-semibold">Bio</h3>
|
|
159
|
+
</CardHeader>
|
|
160
|
+
<Divider />
|
|
161
|
+
<CardBody>
|
|
162
|
+
<p className="text-small text-default-600">{userData.profile.bio}</p>
|
|
163
|
+
</CardBody>
|
|
164
|
+
</Card>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Quick Stats */}
|
|
168
|
+
<Card className="col-span-full">
|
|
169
|
+
<CardHeader>
|
|
170
|
+
<h3 className="text-lg font-semibold">Quick Stats</h3>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
<Divider />
|
|
173
|
+
<CardBody>
|
|
174
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
175
|
+
<div className="text-center">
|
|
176
|
+
<p className="text-2xl font-bold text-primary">0</p>
|
|
177
|
+
<p className="text-small text-default-500">Projects</p>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="text-center">
|
|
180
|
+
<p className="text-2xl font-bold text-success">0</p>
|
|
181
|
+
<p className="text-small text-default-500">Tasks</p>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="text-center">
|
|
184
|
+
<p className="text-2xl font-bold text-warning">0</p>
|
|
185
|
+
<p className="text-small text-default-500">Notifications</p>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="text-center">
|
|
188
|
+
<p className="text-2xl font-bold text-secondary">
|
|
189
|
+
{Math.floor(
|
|
190
|
+
(Date.now() - new Date(userData.created_at).getTime()) /
|
|
191
|
+
(1000 * 60 * 60 * 24)
|
|
192
|
+
)}
|
|
193
|
+
</p>
|
|
194
|
+
<p className="text-small text-default-500">Days active</p>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</CardBody>
|
|
198
|
+
</Card>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardBody,
|
|
7
|
+
CardHeader,
|
|
8
|
+
Input,
|
|
9
|
+
Textarea,
|
|
10
|
+
Button,
|
|
11
|
+
Spinner,
|
|
12
|
+
Divider,
|
|
13
|
+
addToast,
|
|
14
|
+
AvatarUploader,
|
|
15
|
+
} from "@lastbrain/ui";
|
|
16
|
+
import { Save, User } from "lucide-react";
|
|
17
|
+
import { uploadFile, deleteFilesWithPrefix } from "../../api/storage.js";
|
|
18
|
+
import { supabaseBrowserClient } from "@lastbrain/core";
|
|
19
|
+
|
|
20
|
+
interface ProfileData {
|
|
21
|
+
first_name?: string;
|
|
22
|
+
last_name?: string;
|
|
23
|
+
avatar_url?: string;
|
|
24
|
+
bio?: string;
|
|
25
|
+
phone?: string;
|
|
26
|
+
company?: string;
|
|
27
|
+
website?: string;
|
|
28
|
+
location?: string;
|
|
29
|
+
language?: string;
|
|
30
|
+
timezone?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UserMetadata {
|
|
34
|
+
avatar?: string | null;
|
|
35
|
+
avatar_sizes?: {
|
|
36
|
+
small?: string | null;
|
|
37
|
+
medium?: string | null;
|
|
38
|
+
large?: string | null;
|
|
39
|
+
} | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ProfilePage() {
|
|
43
|
+
const [profile, setProfile] = useState<ProfileData>({});
|
|
44
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
45
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
46
|
+
const [error, setError] = useState<string | null>(null);
|
|
47
|
+
const [currentUser, setCurrentUser] = useState<any>(null);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
fetchProfile();
|
|
51
|
+
fetchCurrentUser();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const fetchCurrentUser = async () => {
|
|
55
|
+
try {
|
|
56
|
+
const {
|
|
57
|
+
data: { user },
|
|
58
|
+
} = await supabaseBrowserClient.auth.getUser();
|
|
59
|
+
setCurrentUser(user);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("Error fetching current user:", err);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const fetchProfile = async () => {
|
|
66
|
+
try {
|
|
67
|
+
setIsLoading(true);
|
|
68
|
+
const response = await fetch("/api/auth/profile");
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error("Failed to fetch profile");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await response.json();
|
|
75
|
+
if (result.data) {
|
|
76
|
+
setProfile(result.data);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError(err instanceof Error ? err.message : "An error occurred");
|
|
80
|
+
addToast({
|
|
81
|
+
title: "Error",
|
|
82
|
+
description: "Failed to load profile",
|
|
83
|
+
color: "danger",
|
|
84
|
+
});
|
|
85
|
+
} finally {
|
|
86
|
+
setIsLoading(false);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
setIsSaving(true);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch("/api/auth/profile", {
|
|
96
|
+
method: "PUT",
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify(profile),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error("Failed to update profile");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
addToast({
|
|
108
|
+
title: "Success",
|
|
109
|
+
description: "Profile updated successfully",
|
|
110
|
+
color: "success",
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error("Error updating profile:", err);
|
|
114
|
+
setError(err instanceof Error ? err.message : "An error occurred");
|
|
115
|
+
addToast({
|
|
116
|
+
title: "Error",
|
|
117
|
+
description: "Failed to update profile",
|
|
118
|
+
color: "danger",
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
setIsSaving(false);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const handleChange = (field: keyof ProfileData, value: string) => {
|
|
126
|
+
setProfile((prev) => ({ ...prev, [field]: value }));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleAvatarUpload = async (files: {
|
|
130
|
+
small: Blob;
|
|
131
|
+
medium: Blob;
|
|
132
|
+
large: Blob;
|
|
133
|
+
}) => {
|
|
134
|
+
if (!currentUser) throw new Error("User not authenticated");
|
|
135
|
+
|
|
136
|
+
const version = Date.now();
|
|
137
|
+
const urls = {
|
|
138
|
+
small: "",
|
|
139
|
+
medium: "",
|
|
140
|
+
large: "",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Upload all three sizes
|
|
144
|
+
urls.small = await uploadFile(
|
|
145
|
+
"avatar",
|
|
146
|
+
`${currentUser.id}_32_${version}.webp`,
|
|
147
|
+
files.small,
|
|
148
|
+
"image/webp"
|
|
149
|
+
);
|
|
150
|
+
urls.medium = await uploadFile(
|
|
151
|
+
"avatar",
|
|
152
|
+
`${currentUser.id}_64_${version}.webp`,
|
|
153
|
+
files.medium,
|
|
154
|
+
"image/webp"
|
|
155
|
+
);
|
|
156
|
+
urls.large = await uploadFile(
|
|
157
|
+
"avatar",
|
|
158
|
+
`${currentUser.id}_128_${version}.webp`,
|
|
159
|
+
files.large,
|
|
160
|
+
"image/webp"
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Update user metadata
|
|
164
|
+
await supabaseBrowserClient.auth.updateUser({
|
|
165
|
+
data: {
|
|
166
|
+
avatar: `avatar/${currentUser.id}_128_${version}.webp`,
|
|
167
|
+
avatar_sizes: {
|
|
168
|
+
small: `avatar/${currentUser.id}_32_${version}.webp`,
|
|
169
|
+
medium: `avatar/${currentUser.id}_64_${version}.webp`,
|
|
170
|
+
large: `avatar/${currentUser.id}_128_${version}.webp`,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Update profile avatar_url
|
|
176
|
+
setProfile((prev) => ({ ...prev, avatar_url: urls.large }));
|
|
177
|
+
|
|
178
|
+
return urls;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleAvatarDelete = async () => {
|
|
182
|
+
if (!currentUser) throw new Error("User not authenticated");
|
|
183
|
+
|
|
184
|
+
// Delete old files
|
|
185
|
+
await deleteFilesWithPrefix("avatar", currentUser.id);
|
|
186
|
+
|
|
187
|
+
// Update user metadata
|
|
188
|
+
await supabaseBrowserClient.auth.updateUser({
|
|
189
|
+
data: {
|
|
190
|
+
avatar: null,
|
|
191
|
+
avatar_sizes: {
|
|
192
|
+
small: null,
|
|
193
|
+
medium: null,
|
|
194
|
+
large: null,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Update profile
|
|
200
|
+
setProfile((prev) => ({ ...prev, avatar_url: "" }));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (isLoading) {
|
|
204
|
+
return (
|
|
205
|
+
<div className="flex justify-center items-center min-h-[400px]">
|
|
206
|
+
<Spinner size="lg" label="Loading profile..." />
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className="pt-12 pb-12 max-w-4xl mx-auto px-4">
|
|
213
|
+
<div className="flex items-center gap-2 mb-8">
|
|
214
|
+
<User className="w-8 h-8" />
|
|
215
|
+
<h1 className="text-3xl font-bold">Edit Profile</h1>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<form onSubmit={handleSubmit}>
|
|
219
|
+
<div className="space-y-6">
|
|
220
|
+
{/* Avatar Section */}
|
|
221
|
+
<Card>
|
|
222
|
+
<CardHeader>
|
|
223
|
+
<h3 className="text-lg font-semibold">Photo de profil</h3>
|
|
224
|
+
</CardHeader>
|
|
225
|
+
<Divider />
|
|
226
|
+
<CardBody>
|
|
227
|
+
<div className="flex justify-center">
|
|
228
|
+
<AvatarUploader
|
|
229
|
+
userId={currentUser?.id}
|
|
230
|
+
bucket="avatar"
|
|
231
|
+
shape="circle"
|
|
232
|
+
onUpload={handleAvatarUpload}
|
|
233
|
+
onDelete={handleAvatarDelete}
|
|
234
|
+
initialAvatarPath={
|
|
235
|
+
(currentUser?.user_metadata as UserMetadata)?.avatar ||
|
|
236
|
+
profile.avatar_url ||
|
|
237
|
+
null
|
|
238
|
+
}
|
|
239
|
+
initialAvatarSizes={(() => {
|
|
240
|
+
const sizes = (currentUser?.user_metadata as UserMetadata)
|
|
241
|
+
?.avatar_sizes;
|
|
242
|
+
if (!sizes) return null;
|
|
243
|
+
return {
|
|
244
|
+
small: sizes.small ?? null,
|
|
245
|
+
medium: sizes.medium ?? null,
|
|
246
|
+
large: sizes.large ?? null,
|
|
247
|
+
};
|
|
248
|
+
})()}
|
|
249
|
+
onUploaded={(urls) => {
|
|
250
|
+
setProfile((prev) => ({ ...prev, avatar_url: urls.large }));
|
|
251
|
+
}}
|
|
252
|
+
onDeleted={() => {
|
|
253
|
+
setProfile((prev) => ({ ...prev, avatar_url: "" }));
|
|
254
|
+
}}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
</CardBody>
|
|
258
|
+
</Card>
|
|
259
|
+
|
|
260
|
+
{/* Personal Information */}
|
|
261
|
+
<Card>
|
|
262
|
+
<CardHeader>
|
|
263
|
+
<h3 className="text-lg font-semibold">Personal Information</h3>
|
|
264
|
+
</CardHeader>
|
|
265
|
+
<Divider />
|
|
266
|
+
<CardBody>
|
|
267
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
268
|
+
<Input
|
|
269
|
+
label="First Name"
|
|
270
|
+
placeholder="Enter your first name"
|
|
271
|
+
value={profile.first_name || ""}
|
|
272
|
+
onChange={(e) => handleChange("first_name", e.target.value)}
|
|
273
|
+
/>
|
|
274
|
+
<Input
|
|
275
|
+
label="Last Name"
|
|
276
|
+
placeholder="Enter your last name"
|
|
277
|
+
value={profile.last_name || ""}
|
|
278
|
+
onChange={(e) => handleChange("last_name", e.target.value)}
|
|
279
|
+
/>
|
|
280
|
+
<Input
|
|
281
|
+
label="Phone"
|
|
282
|
+
placeholder="Enter your phone number"
|
|
283
|
+
type="tel"
|
|
284
|
+
value={profile.phone || ""}
|
|
285
|
+
onChange={(e) => handleChange("phone", e.target.value)}
|
|
286
|
+
className="md:col-span-2"
|
|
287
|
+
/>
|
|
288
|
+
<Textarea
|
|
289
|
+
label="Bio"
|
|
290
|
+
placeholder="Tell us about yourself"
|
|
291
|
+
value={profile.bio || ""}
|
|
292
|
+
onChange={(e) => handleChange("bio", e.target.value)}
|
|
293
|
+
minRows={3}
|
|
294
|
+
className="md:col-span-2"
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
</CardBody>
|
|
298
|
+
</Card>
|
|
299
|
+
|
|
300
|
+
{/* Professional Information */}
|
|
301
|
+
<Card>
|
|
302
|
+
<CardHeader>
|
|
303
|
+
<h3 className="text-lg font-semibold">
|
|
304
|
+
Professional Information
|
|
305
|
+
</h3>
|
|
306
|
+
</CardHeader>
|
|
307
|
+
<Divider />
|
|
308
|
+
<CardBody>
|
|
309
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
310
|
+
<Input
|
|
311
|
+
label="Company"
|
|
312
|
+
placeholder="Enter your company name"
|
|
313
|
+
value={profile.company || ""}
|
|
314
|
+
onChange={(e) => handleChange("company", e.target.value)}
|
|
315
|
+
/>
|
|
316
|
+
<Input
|
|
317
|
+
label="Website"
|
|
318
|
+
placeholder="https://example.com"
|
|
319
|
+
type="url"
|
|
320
|
+
value={profile.website || ""}
|
|
321
|
+
onChange={(e) => handleChange("website", e.target.value)}
|
|
322
|
+
/>
|
|
323
|
+
<Input
|
|
324
|
+
label="Location"
|
|
325
|
+
placeholder="City, Country"
|
|
326
|
+
value={profile.location || ""}
|
|
327
|
+
onChange={(e) => handleChange("location", e.target.value)}
|
|
328
|
+
className="md:col-span-2"
|
|
329
|
+
/>
|
|
330
|
+
</div>
|
|
331
|
+
</CardBody>
|
|
332
|
+
</Card>
|
|
333
|
+
|
|
334
|
+
{/* Preferences */}
|
|
335
|
+
<Card>
|
|
336
|
+
<CardHeader>
|
|
337
|
+
<h3 className="text-lg font-semibold">Preferences</h3>
|
|
338
|
+
</CardHeader>
|
|
339
|
+
<Divider />
|
|
340
|
+
<CardBody>
|
|
341
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
342
|
+
<Input
|
|
343
|
+
label="Language"
|
|
344
|
+
placeholder="en, fr, es..."
|
|
345
|
+
value={profile.language || ""}
|
|
346
|
+
onChange={(e) => handleChange("language", e.target.value)}
|
|
347
|
+
/>
|
|
348
|
+
<Input
|
|
349
|
+
label="Timezone"
|
|
350
|
+
placeholder="Europe/Paris, America/New_York..."
|
|
351
|
+
value={profile.timezone || ""}
|
|
352
|
+
onChange={(e) => handleChange("timezone", e.target.value)}
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
355
|
+
</CardBody>
|
|
356
|
+
</Card>
|
|
357
|
+
|
|
358
|
+
{/* Actions */}
|
|
359
|
+
<div className="flex justify-end gap-3">
|
|
360
|
+
<Button
|
|
361
|
+
type="button"
|
|
362
|
+
variant="flat"
|
|
363
|
+
onPress={() => fetchProfile()}
|
|
364
|
+
isDisabled={isSaving}
|
|
365
|
+
>
|
|
366
|
+
Cancel
|
|
367
|
+
</Button>
|
|
368
|
+
<Button
|
|
369
|
+
type="submit"
|
|
370
|
+
color="primary"
|
|
371
|
+
isLoading={isSaving}
|
|
372
|
+
startContent={!isSaving && <Save className="w-4 h-4" />}
|
|
373
|
+
>
|
|
374
|
+
{isSaving ? "Saving..." : "Save Changes"}
|
|
375
|
+
</Button>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</form>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|