@m5kdev/web-ui 0.1.0

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.
Files changed (127) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +17 -0
  3. package/package.json +169 -0
  4. package/src/animations/card.motion.ts +9 -0
  5. package/src/components/AvatarUpload.tsx +133 -0
  6. package/src/components/Button.tsx +14 -0
  7. package/src/components/Calendar.css +684 -0
  8. package/src/components/Calendar.tsx +32 -0
  9. package/src/components/CardsSelect.tsx +155 -0
  10. package/src/components/CollapsibleSidebarMenuItem.tsx +57 -0
  11. package/src/components/ColorPicker.tsx +56 -0
  12. package/src/components/CopyButton.tsx +45 -0
  13. package/src/components/CropDialog.tsx +154 -0
  14. package/src/components/DialogProvider.tsx +105 -0
  15. package/src/components/ErrorFallback.tsx +17 -0
  16. package/src/components/FileDropzone.tsx +120 -0
  17. package/src/components/MultiSelectDropdown.tsx +233 -0
  18. package/src/components/Orb.tsx +288 -0
  19. package/src/components/PageAlert.tsx +121 -0
  20. package/src/components/SelectChips.tsx +40 -0
  21. package/src/components/SidebarItem.tsx +26 -0
  22. package/src/components/Steps.tsx +340 -0
  23. package/src/components/TablerIconPicker.tsx +4260 -0
  24. package/src/components/app-header.tsx +40 -0
  25. package/src/components/blur-card.tsx +132 -0
  26. package/src/components/features-section-demo-1.tsx +127 -0
  27. package/src/components/features-section-demo-2.tsx +102 -0
  28. package/src/components/features-section-demo-3.tsx +272 -0
  29. package/src/components/mode-toggle.tsx +31 -0
  30. package/src/components/nav-main.tsx +69 -0
  31. package/src/components/pricing-cards.tsx +133 -0
  32. package/src/components/shared/ButtonCopy.tsx +50 -0
  33. package/src/components/team-switcher.tsx +83 -0
  34. package/src/components/theme-provider.tsx +74 -0
  35. package/src/components/typewriter.tsx +90 -0
  36. package/src/components/ui/alert-dialog.tsx +133 -0
  37. package/src/components/ui/alert.tsx +60 -0
  38. package/src/components/ui/avatar.tsx +47 -0
  39. package/src/components/ui/badge.tsx +33 -0
  40. package/src/components/ui/bento-grid.tsx +54 -0
  41. package/src/components/ui/bento-grid2.tsx +66 -0
  42. package/src/components/ui/breadcrumb.tsx +101 -0
  43. package/src/components/ui/button.tsx +50 -0
  44. package/src/components/ui/card.tsx +55 -0
  45. package/src/components/ui/checkbox.tsx +26 -0
  46. package/src/components/ui/collapsible.tsx +9 -0
  47. package/src/components/ui/dialog.tsx +119 -0
  48. package/src/components/ui/dropdown-menu.tsx +186 -0
  49. package/src/components/ui/floating-navbar.tsx +78 -0
  50. package/src/components/ui/form.tsx +167 -0
  51. package/src/components/ui/image.tsx +55 -0
  52. package/src/components/ui/input.tsx +22 -0
  53. package/src/components/ui/label.tsx +19 -0
  54. package/src/components/ui/pagination.tsx +105 -0
  55. package/src/components/ui/progress.tsx +23 -0
  56. package/src/components/ui/resizable-navbar.tsx +260 -0
  57. package/src/components/ui/segment-control.tsx +143 -0
  58. package/src/components/ui/select.tsx +153 -0
  59. package/src/components/ui/separator.tsx +24 -0
  60. package/src/components/ui/sheet.tsx +121 -0
  61. package/src/components/ui/sidebar.tsx +736 -0
  62. package/src/components/ui/skeleton.tsx +7 -0
  63. package/src/components/ui/slider.tsx +23 -0
  64. package/src/components/ui/sonner.tsx +27 -0
  65. package/src/components/ui/spinner.tsx +45 -0
  66. package/src/components/ui/switch.tsx +27 -0
  67. package/src/components/ui/table.tsx +90 -0
  68. package/src/components/ui/tabs.tsx +52 -0
  69. package/src/components/ui/textarea.tsx +18 -0
  70. package/src/components/ui/timeline.tsx +95 -0
  71. package/src/components/ui/toast.tsx +126 -0
  72. package/src/components/ui/tooltip.tsx +55 -0
  73. package/src/components/ui/typewriter-effect.tsx +181 -0
  74. package/src/hooks/use-mobile.ts +19 -0
  75. package/src/hooks/useDialog.ts +25 -0
  76. package/src/icons/GoogleIcon.tsx +32 -0
  77. package/src/icons/LinkedInIcon.tsx +30 -0
  78. package/src/icons/MicrosoftIcon.tsx +21 -0
  79. package/src/lib/chatwoot.ts +51 -0
  80. package/src/lib/utils.ts +6 -0
  81. package/src/modules/app/components/AppLoader.tsx +9 -0
  82. package/src/modules/app/components/AppShell.tsx +21 -0
  83. package/src/modules/app/components/AppSidebar.tsx +26 -0
  84. package/src/modules/app/components/AppSidebarContent.tsx +73 -0
  85. package/src/modules/app/components/AppSidebarHeader.tsx +57 -0
  86. package/src/modules/app/components/AppSidebarInvites.tsx +32 -0
  87. package/src/modules/app/components/AppSidebarUser.tsx +128 -0
  88. package/src/modules/auth/components/AdminUserManagement.tsx +1136 -0
  89. package/src/modules/auth/components/AdminWaitlist.tsx +358 -0
  90. package/src/modules/auth/components/AuthLayout.tsx +13 -0
  91. package/src/modules/auth/components/AuthProviders.tsx +105 -0
  92. package/src/modules/auth/components/AuthRouter.tsx +29 -0
  93. package/src/modules/auth/components/ClaimAccountRoute.tsx +242 -0
  94. package/src/modules/auth/components/ErrorAuthRoute.tsx +121 -0
  95. package/src/modules/auth/components/ForgotPasswordForm.tsx +58 -0
  96. package/src/modules/auth/components/ForgotPasswordRoute.tsx +27 -0
  97. package/src/modules/auth/components/InviteFriends.tsx +273 -0
  98. package/src/modules/auth/components/LastUsedBadge.tsx +22 -0
  99. package/src/modules/auth/components/LoginForm.tsx +104 -0
  100. package/src/modules/auth/components/LoginRoute.tsx +31 -0
  101. package/src/modules/auth/components/LogoutRoute.tsx +21 -0
  102. package/src/modules/auth/components/OrganizationAcceptInvitationRoute.tsx +161 -0
  103. package/src/modules/auth/components/OrganizationMembersRoute.tsx +730 -0
  104. package/src/modules/auth/components/OrganizationSettingsRoute.tsx +280 -0
  105. package/src/modules/auth/components/OrganizationSwitcher.tsx +148 -0
  106. package/src/modules/auth/components/ProfileRoute.tsx +104 -0
  107. package/src/modules/auth/components/RangeNuqsDatePicker.tsx +365 -0
  108. package/src/modules/auth/components/ResetPasswordForm.tsx +103 -0
  109. package/src/modules/auth/components/ResetPasswordRoute.tsx +27 -0
  110. package/src/modules/auth/components/SignupFormRoute.tsx +189 -0
  111. package/src/modules/auth/components/SignupRoute.tsx +53 -0
  112. package/src/modules/auth/components/UserPreferences.tsx +144 -0
  113. package/src/modules/auth/components/WaitlistCard.tsx +78 -0
  114. package/src/modules/auth/components/WaitlistCodeValidation.tsx +79 -0
  115. package/src/modules/billing/components/BillingBetaPage.tsx +124 -0
  116. package/src/modules/billing/components/BillingInvoicePage.tsx +180 -0
  117. package/src/modules/billing/components/BillingPlanSelect.tsx +14 -0
  118. package/src/modules/billing/components/BillingRouter.tsx +20 -0
  119. package/src/modules/billing/components/BillingSinglePlanSelect.tsx +172 -0
  120. package/src/modules/table/components/ColumnOrderAndVisibility.tsx +127 -0
  121. package/src/modules/table/components/NuqsTable.tsx +396 -0
  122. package/src/modules/table/components/TableFiltering.tsx +520 -0
  123. package/src/modules/table/components/TablePagination.tsx +59 -0
  124. package/src/modules/table/components/table.types.ts +11 -0
  125. package/src/modules/table/filterTransformers.ts +323 -0
  126. package/src/types.ts +4 -0
  127. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,280 @@
1
+ import { Button, Card, CardBody, CardHeader, Chip, Input, Spinner, Textarea } from "@heroui/react";
2
+ import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
3
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
4
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useCallback, useEffect, useMemo, useState } from "react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { toast } from "sonner";
8
+
9
+ type OrganizationDetails = {
10
+ id: string;
11
+ name: string;
12
+ slug: string;
13
+ logo?: string | null;
14
+ metadata?: Record<string, unknown> | null;
15
+ };
16
+
17
+ export type OrganizationSettingsRouteLabels = {
18
+ settingsTitle: string;
19
+ settingsDescription: string;
20
+ settingsNoActive: string;
21
+ settingsManageOnly: string;
22
+ formName: string;
23
+ formSlug: string;
24
+ formMetadata: string;
25
+ formMetadataInvalidJson: string;
26
+ formMetadataInvalid: string;
27
+ saveButton: string;
28
+ updateSuccess: string;
29
+ updateError: string;
30
+ loadError: string;
31
+ };
32
+
33
+ export type OrganizationSettingsRouteProps = {
34
+ managerRoles?: string[];
35
+ onInvalidateScopedQueries?: () => void | Promise<void>;
36
+ };
37
+
38
+ function OrganizationStateCard({ title, message }: { title: string; message: string }) {
39
+ return (
40
+ <div className="p-6">
41
+ <Card>
42
+ <CardHeader className="text-lg font-semibold">{title}</CardHeader>
43
+ <CardBody>{message}</CardBody>
44
+ </Card>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function useOrganizationAccess({
50
+ managerRoles,
51
+ onInvalidateScopedQueries,
52
+ }: Pick<OrganizationSettingsRouteProps, "managerRoles" | "onInvalidateScopedQueries">) {
53
+ const { data: session, registerSession } = useSession();
54
+ const queryClient = useQueryClient();
55
+
56
+ const activeOrganizationId = session?.session.activeOrganizationId ?? "";
57
+ const activeOrganizationRole =
58
+ (session?.session as { activeOrganizationRole?: string } | undefined)?.activeOrganizationRole ??
59
+ "";
60
+ const managerRoleSet = useMemo(() => new Set(managerRoles ?? ["admin", "owner"]), [managerRoles]);
61
+ const canManageOrganization = managerRoleSet.has(activeOrganizationRole);
62
+
63
+ const refreshOrganizationQueries = useCallback(async () => {
64
+ await Promise.allSettled([
65
+ queryClient.invalidateQueries({ queryKey: ["auth-organization-list"] }),
66
+ queryClient.invalidateQueries({
67
+ queryKey: ["auth-organization-details", activeOrganizationId],
68
+ }),
69
+ queryClient.invalidateQueries({
70
+ queryKey: ["auth-organization-members", activeOrganizationId],
71
+ }),
72
+ queryClient.invalidateQueries({
73
+ queryKey: ["auth-organization-invitations", activeOrganizationId],
74
+ }),
75
+ ]);
76
+
77
+ registerSession(() => {
78
+ void onInvalidateScopedQueries?.();
79
+ });
80
+ }, [activeOrganizationId, onInvalidateScopedQueries, queryClient, registerSession]);
81
+
82
+ return {
83
+ activeOrganizationId,
84
+ activeOrganizationRole,
85
+ canManageOrganization,
86
+ refreshOrganizationQueries,
87
+ };
88
+ }
89
+
90
+ function useOrganizationConfig() {
91
+ const { t } = useTranslation();
92
+
93
+ const translatedLabels = useMemo<OrganizationSettingsRouteLabels>(
94
+ () => ({
95
+ settingsTitle: t("web-ui:organization.settings.title"),
96
+ settingsDescription: t("web-ui:organization.settings.description"),
97
+ settingsNoActive: t("web-ui:organization.settings.noActive"),
98
+ settingsManageOnly: t("web-ui:organization.settings.manageOnly"),
99
+ formName: t("web-ui:organization.settings.form.name"),
100
+ formSlug: t("web-ui:organization.settings.form.slug"),
101
+ formMetadata: t("web-ui:organization.settings.form.metadata"),
102
+ formMetadataInvalidJson: t("web-ui:organization.settings.form.metadataInvalidJson"),
103
+ formMetadataInvalid: t("web-ui:organization.settings.form.metadataInvalid"),
104
+ saveButton: t("web-ui:organization.settings.button.save"),
105
+ updateSuccess: t("web-ui:organization.settings.updateSuccess"),
106
+ updateError: t("web-ui:organization.settings.updateError"),
107
+ loadError: t("web-ui:organization.settings.loadError"),
108
+ }),
109
+ [t]
110
+ );
111
+
112
+ const translatedRoleLabels = useMemo<Record<string, string>>(
113
+ () => ({
114
+ member: t("web-ui:organization.roles.member"),
115
+ admin: t("web-ui:organization.roles.admin"),
116
+ owner: t("web-ui:organization.roles.owner"),
117
+ }),
118
+ [t]
119
+ );
120
+
121
+ return { resolvedLabels: translatedLabels, resolvedRoleLabels: translatedRoleLabels };
122
+ }
123
+
124
+ export function OrganizationSettingsRoute({
125
+ managerRoles,
126
+ onInvalidateScopedQueries,
127
+ }: OrganizationSettingsRouteProps) {
128
+ const { resolvedLabels, resolvedRoleLabels } = useOrganizationConfig();
129
+
130
+ const {
131
+ activeOrganizationId,
132
+ activeOrganizationRole,
133
+ canManageOrganization,
134
+ refreshOrganizationQueries,
135
+ } = useOrganizationAccess({ managerRoles, onInvalidateScopedQueries });
136
+
137
+ const [organizationName, setOrganizationName] = useState("");
138
+ const [organizationSlug, setOrganizationSlug] = useState("");
139
+ const [organizationMetadata, setOrganizationMetadata] = useState("{}");
140
+ const [isSavingOrganization, setIsSavingOrganization] = useState(false);
141
+
142
+ const organizationQuery = useQuery({
143
+ queryKey: ["auth-organization-details", activeOrganizationId],
144
+ enabled: Boolean(activeOrganizationId && canManageOrganization),
145
+ queryFn: async () => {
146
+ const { data, error } = await authClient.organization.getFullOrganization({
147
+ query: {
148
+ organizationId: activeOrganizationId,
149
+ membersLimit: 200,
150
+ },
151
+ });
152
+ if (error) {
153
+ throw new Error(error.message ?? resolvedLabels.loadError);
154
+ }
155
+ return data as OrganizationDetails | null;
156
+ },
157
+ });
158
+
159
+ useEffect(() => {
160
+ const organization = organizationQuery.data;
161
+ if (!organization) {
162
+ return;
163
+ }
164
+
165
+ setOrganizationName(organization.name ?? "");
166
+ setOrganizationSlug(organization.slug ?? "");
167
+ setOrganizationMetadata(JSON.stringify(organization.metadata ?? {}, null, 2));
168
+ }, [organizationQuery.data]);
169
+
170
+ const onUpdateOrganization = async () => {
171
+ if (!canManageOrganization || !activeOrganizationId) {
172
+ return;
173
+ }
174
+
175
+ try {
176
+ setIsSavingOrganization(true);
177
+ let parsedMetadata: Record<string, unknown>;
178
+ try {
179
+ parsedMetadata = organizationMetadata.trim()
180
+ ? (JSON.parse(organizationMetadata) as Record<string, unknown>)
181
+ : {};
182
+ } catch (parseError) {
183
+ const message =
184
+ parseError instanceof SyntaxError
185
+ ? resolvedLabels.formMetadataInvalidJson
186
+ : parseError instanceof Error
187
+ ? parseError.message
188
+ : resolvedLabels.formMetadataInvalid;
189
+ throw new Error(message);
190
+ }
191
+
192
+ const { error } = await authClient.organization.update({
193
+ organizationId: activeOrganizationId,
194
+ data: {
195
+ name: organizationName.trim(),
196
+ slug: organizationSlug.trim(),
197
+ metadata: parsedMetadata,
198
+ },
199
+ });
200
+
201
+ if (error) {
202
+ throw new Error(error.message ?? resolvedLabels.updateError);
203
+ }
204
+
205
+ await refreshOrganizationQueries();
206
+ toast.success(resolvedLabels.updateSuccess);
207
+ } catch (error) {
208
+ toast.error(error instanceof Error ? error.message : resolvedLabels.updateError);
209
+ } finally {
210
+ setIsSavingOrganization(false);
211
+ }
212
+ };
213
+
214
+ if (!activeOrganizationId) {
215
+ return (
216
+ <OrganizationStateCard
217
+ title={resolvedLabels.settingsTitle}
218
+ message={resolvedLabels.settingsNoActive}
219
+ />
220
+ );
221
+ }
222
+
223
+ if (!canManageOrganization) {
224
+ return (
225
+ <OrganizationStateCard
226
+ title={resolvedLabels.settingsTitle}
227
+ message={resolvedLabels.settingsManageOnly}
228
+ />
229
+ );
230
+ }
231
+
232
+ if (organizationQuery.isLoading) {
233
+ return (
234
+ <div className="p-6 flex justify-center">
235
+ <Spinner />
236
+ </div>
237
+ );
238
+ }
239
+
240
+ return (
241
+ <div className="p-6">
242
+ <Card>
243
+ <CardHeader className="flex items-center justify-between">
244
+ <div className="flex flex-col">
245
+ <h2 className="text-xl font-semibold">{resolvedLabels.settingsTitle}</h2>
246
+ <p className="text-sm text-default-500">{resolvedLabels.settingsDescription}</p>
247
+ </div>
248
+ <Chip variant="flat" color="primary">
249
+ {resolvedRoleLabels[activeOrganizationRole] ??
250
+ activeOrganizationRole ??
251
+ resolvedRoleLabels.member}
252
+ </Chip>
253
+ </CardHeader>
254
+ <CardBody className="grid gap-3">
255
+ <Input
256
+ label={resolvedLabels.formName}
257
+ value={organizationName}
258
+ onValueChange={setOrganizationName}
259
+ />
260
+ <Input
261
+ label={resolvedLabels.formSlug}
262
+ value={organizationSlug}
263
+ onValueChange={setOrganizationSlug}
264
+ />
265
+ <Textarea
266
+ label={resolvedLabels.formMetadata}
267
+ value={organizationMetadata}
268
+ onValueChange={setOrganizationMetadata}
269
+ minRows={4}
270
+ />
271
+ <div className="flex justify-end">
272
+ <Button color="primary" isLoading={isSavingOrganization} onPress={onUpdateOrganization}>
273
+ {resolvedLabels.saveButton}
274
+ </Button>
275
+ </div>
276
+ </CardBody>
277
+ </Card>
278
+ </div>
279
+ );
280
+ }
@@ -0,0 +1,148 @@
1
+ import { Button, Select, SelectItem } from "@heroui/react";
2
+ import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
3
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { Building2 } from "lucide-react";
6
+ import { useCallback, useMemo, useState } from "react";
7
+ import { useTranslation } from "react-i18next";
8
+ import { Link } from "react-router";
9
+ import { toast } from "sonner";
10
+ import { useSidebar } from "#components/ui/sidebar";
11
+ import { cn } from "#utils";
12
+
13
+ type OrganizationOption = {
14
+ id: string;
15
+ name: string;
16
+ slug: string;
17
+ };
18
+
19
+ export type OrganizationSwitcherProps = {
20
+ onInvalidateScopedQueries?: () => void | Promise<void>;
21
+ managerRoles?: string[];
22
+ managerPath?: string;
23
+ fallbackPath?: string;
24
+ };
25
+
26
+ export function OrganizationSwitcher({
27
+ onInvalidateScopedQueries,
28
+ managerRoles = ["admin", "owner"],
29
+ managerPath = "/organization/members",
30
+ fallbackPath = "/",
31
+ }: OrganizationSwitcherProps) {
32
+ const { t } = useTranslation();
33
+ const { data: session, registerSession } = useSession();
34
+ const { open } = useSidebar();
35
+ const [isSwitching, setIsSwitching] = useState(false);
36
+ const activeOrganizationId = session?.session.activeOrganizationId ?? null;
37
+ const activeOrganizationRole =
38
+ (session?.session as { activeOrganizationRole?: string } | undefined)?.activeOrganizationRole ??
39
+ "";
40
+ const managerRoleSet = useMemo(() => new Set(managerRoles), [managerRoles]);
41
+ const canManageOrganization = managerRoleSet.has(activeOrganizationRole);
42
+
43
+ const {
44
+ data: organizations = [],
45
+ isError,
46
+ error,
47
+ refetch,
48
+ } = useQuery({
49
+ queryKey: ["auth-organization-list"],
50
+ queryFn: async () => {
51
+ const { data, error } = await authClient.organization.list();
52
+ if (error) {
53
+ throw new Error(
54
+ error.message ?? t("web-ui:organization.switcher.failedToLoadOrganizations")
55
+ );
56
+ }
57
+ return (data ?? []) as OrganizationOption[];
58
+ },
59
+ });
60
+
61
+ const handleSwitchOrganization = useCallback(
62
+ async (organizationId: string) => {
63
+ if (!organizationId || organizationId === activeOrganizationId || isSwitching) {
64
+ return;
65
+ }
66
+
67
+ try {
68
+ setIsSwitching(true);
69
+ const { error } = await authClient.organization.setActive({ organizationId });
70
+ if (error) {
71
+ throw new Error(
72
+ error.message ?? t("web-ui:organization.switcher.failedToSwitchOrganization")
73
+ );
74
+ }
75
+
76
+ registerSession(() => {
77
+ void onInvalidateScopedQueries?.();
78
+ });
79
+ toast.success(t("web-ui:organization.switcher.organizationSwitched"));
80
+ } catch (error) {
81
+ toast.error(
82
+ error instanceof Error
83
+ ? error.message
84
+ : t("web-ui:organization.switcher.failedToSwitchOrganization")
85
+ );
86
+ } finally {
87
+ setIsSwitching(false);
88
+ }
89
+ },
90
+ [activeOrganizationId, isSwitching, onInvalidateScopedQueries, registerSession, t]
91
+ );
92
+
93
+ if (!open) {
94
+ return (
95
+ <Button
96
+ as={Link}
97
+ to={canManageOrganization ? managerPath : fallbackPath}
98
+ variant="light"
99
+ size="sm"
100
+ isIconOnly
101
+ aria-label={t("web-ui:organization.switcher.label")}
102
+ >
103
+ <Building2 className="h-4 w-4" />
104
+ </Button>
105
+ );
106
+ }
107
+
108
+ if (isError) {
109
+ return (
110
+ <div className="mb-4 flex flex-col gap-2">
111
+ <p className="text-sm text-destructive">
112
+ {error instanceof Error
113
+ ? error.message
114
+ : t("web-ui:organization.switcher.failedToLoadOrganizations")}
115
+ </p>
116
+ <Button size="sm" variant="flat" onPress={() => void refetch()}>
117
+ {t("web-ui:organization.switcher.retry")}
118
+ </Button>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <div className="mb-4">
125
+ <Select
126
+ size="sm"
127
+ label={t("web-ui:organization.switcher.label")}
128
+ selectedKeys={activeOrganizationId ? [activeOrganizationId] : []}
129
+ disallowEmptySelection
130
+ isDisabled={isSwitching || organizations.length === 0}
131
+ onSelectionChange={(keys) => {
132
+ const selectedOrganizationId = Array.from(keys as Set<string>)[0];
133
+ if (selectedOrganizationId) {
134
+ void handleSwitchOrganization(selectedOrganizationId);
135
+ }
136
+ }}
137
+ classNames={{
138
+ trigger: cn("min-h-10"),
139
+ value: cn("text-sm"),
140
+ }}
141
+ >
142
+ {organizations.map((organization) => (
143
+ <SelectItem key={organization.id}>{organization.name}</SelectItem>
144
+ ))}
145
+ </Select>
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,104 @@
1
+ import { Button, Card, CardBody, CardHeader, Input } from "@heroui/react";
2
+
3
+ import { authClient } from "@m5kdev/frontend/modules/auth/auth.lib";
4
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
5
+ import { useForm } from "react-hook-form";
6
+ import { useTranslation } from "react-i18next";
7
+ import { toast } from "sonner";
8
+ import { z } from "zod";
9
+ import { AvatarUpload } from "#components/AvatarUpload";
10
+ import {
11
+ Form,
12
+ FormControl,
13
+ FormField,
14
+ FormItem,
15
+ FormLabel,
16
+ FormMessage,
17
+ } from "#components/ui/form";
18
+
19
+ const profileSchema = z.object({
20
+ name: z.string().min(2, "Name must be at least 2 characters"),
21
+ image: z.string().nullable(),
22
+ });
23
+
24
+ type ProfileFormValues = z.infer<typeof profileSchema>;
25
+
26
+ export function ProfileRoute() {
27
+ const { t } = useTranslation();
28
+ const { data: session } = useSession();
29
+
30
+ const form = useForm<ProfileFormValues>({
31
+ defaultValues: {
32
+ name: session?.user?.name || "",
33
+ image: session?.user?.image || null,
34
+ },
35
+ });
36
+
37
+ function onSubmit(data: ProfileFormValues) {
38
+ authClient
39
+ .updateUser(data)
40
+ .then(() => {
41
+ toast.success(t("web-ui:profile.updated"), {
42
+ description: t("web-ui:profile.updateDescription"),
43
+ });
44
+ })
45
+ .catch(() => {
46
+ toast.error(t("web-ui:profile.error"), {
47
+ description: t("web-ui:profile.errorDescription"),
48
+ });
49
+ });
50
+ }
51
+
52
+ return (
53
+ <div className="container py-10 px-4">
54
+ <Card>
55
+ <CardHeader className="flex flex-col gap-1">
56
+ <p className="text-xl font-semibold">{t("web-ui:profile.settings.title")}</p>
57
+ <p className="text-sm text-default-600">{t("web-ui:profile.settings.description")}</p>
58
+ </CardHeader>
59
+ <CardBody>
60
+ <Form {...form}>
61
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
62
+ <div className="flex justify-center">
63
+ <FormField
64
+ control={form.control}
65
+ name="image"
66
+ render={({ field }) => (
67
+ <FormItem>
68
+ <FormControl>
69
+ <AvatarUpload
70
+ currentAvatarUrl={field.value}
71
+ onUploadComplete={(url) => {
72
+ console.log(url);
73
+ field.onChange(url);
74
+ }}
75
+ />
76
+ </FormControl>
77
+ <FormMessage />
78
+ </FormItem>
79
+ )}
80
+ />
81
+ </div>
82
+
83
+ <FormField
84
+ control={form.control}
85
+ name="name"
86
+ render={({ field }) => (
87
+ <FormItem>
88
+ <FormLabel>Name</FormLabel>
89
+ <FormControl>
90
+ <Input placeholder={t("web-ui:profile.placeholders.name")} {...field} />
91
+ </FormControl>
92
+ <FormMessage />
93
+ </FormItem>
94
+ )}
95
+ />
96
+
97
+ <Button type="submit">Save Changes</Button>
98
+ </form>
99
+ </Form>
100
+ </CardBody>
101
+ </Card>
102
+ </div>
103
+ );
104
+ }