@greatapps/common 1.1.24 → 1.1.25

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 (125) hide show
  1. package/dist/components/account/ConfigurationsMyAccountModal.mjs +121 -0
  2. package/dist/components/account/ConfigurationsMyAccountModal.mjs.map +1 -0
  3. package/dist/components/account/ConfirmGlobalPreferencesModal.mjs +45 -0
  4. package/dist/components/account/ConfirmGlobalPreferencesModal.mjs.map +1 -0
  5. package/dist/components/account/DisableTwoFactorAuthModal.mjs +67 -0
  6. package/dist/components/account/DisableTwoFactorAuthModal.mjs.map +1 -0
  7. package/dist/components/account/TwoFactorAuthModal.mjs +190 -0
  8. package/dist/components/account/TwoFactorAuthModal.mjs.map +1 -0
  9. package/dist/components/account/constants.mjs +32 -0
  10. package/dist/components/account/constants.mjs.map +1 -0
  11. package/dist/components/account/hooks/useChangeEmailForm.mjs +53 -0
  12. package/dist/components/account/hooks/useChangeEmailForm.mjs.map +1 -0
  13. package/dist/components/account/hooks/useChangePasswordForm.mjs +47 -0
  14. package/dist/components/account/hooks/useChangePasswordForm.mjs.map +1 -0
  15. package/dist/components/account/hooks/useChangePhoneForm.mjs +64 -0
  16. package/dist/components/account/hooks/useChangePhoneForm.mjs.map +1 -0
  17. package/dist/components/account/hooks/useConfigurationsModal.mjs +33 -0
  18. package/dist/components/account/hooks/useConfigurationsModal.mjs.map +1 -0
  19. package/dist/components/account/hooks/useDisableTwoFactorAuthForm.mjs +54 -0
  20. package/dist/components/account/hooks/useDisableTwoFactorAuthForm.mjs.map +1 -0
  21. package/dist/components/account/hooks/useOtpVerification.mjs +94 -0
  22. package/dist/components/account/hooks/useOtpVerification.mjs.map +1 -0
  23. package/dist/components/account/hooks/useTwoFactorAuthForm.mjs +88 -0
  24. package/dist/components/account/hooks/useTwoFactorAuthForm.mjs.map +1 -0
  25. package/dist/components/account/sections/ChangeEmailModal.mjs +180 -0
  26. package/dist/components/account/sections/ChangeEmailModal.mjs.map +1 -0
  27. package/dist/components/account/sections/ChangePasswordSection.mjs +64 -0
  28. package/dist/components/account/sections/ChangePasswordSection.mjs.map +1 -0
  29. package/dist/components/account/sections/ChangePhoneModal.mjs +182 -0
  30. package/dist/components/account/sections/ChangePhoneModal.mjs.map +1 -0
  31. package/dist/components/account/sections/MyProfileSection.mjs +312 -0
  32. package/dist/components/account/sections/MyProfileSection.mjs.map +1 -0
  33. package/dist/components/account/sections/PreferencesSection.mjs +246 -0
  34. package/dist/components/account/sections/PreferencesSection.mjs.map +1 -0
  35. package/dist/components/account/sections/SecuritySection.mjs +147 -0
  36. package/dist/components/account/sections/SecuritySection.mjs.map +1 -0
  37. package/dist/components/ui/form/ComboboxField.mjs +130 -0
  38. package/dist/components/ui/form/ComboboxField.mjs.map +1 -0
  39. package/dist/components/ui/form/FormField.mjs +103 -0
  40. package/dist/components/ui/form/FormField.mjs.map +1 -0
  41. package/dist/components/ui/form/PhoneInput.mjs +78 -0
  42. package/dist/components/ui/form/PhoneInput.mjs.map +1 -0
  43. package/dist/components/ui/form/SelectField.mjs +80 -0
  44. package/dist/components/ui/form/SelectField.mjs.map +1 -0
  45. package/dist/components/ui/form/SwitchOptionFieldWithIcon.mjs +40 -0
  46. package/dist/components/ui/form/SwitchOptionFieldWithIcon.mjs.map +1 -0
  47. package/dist/components/ui/form/TextAreaField.mjs +56 -0
  48. package/dist/components/ui/form/TextAreaField.mjs.map +1 -0
  49. package/dist/enums/AccountSectionType.mjs +11 -0
  50. package/dist/enums/AccountSectionType.mjs.map +1 -0
  51. package/dist/hooks/useCountdownTimer.mjs +29 -0
  52. package/dist/hooks/useCountdownTimer.mjs.map +1 -0
  53. package/dist/hooks/useIsMobile.mjs +18 -0
  54. package/dist/hooks/useIsMobile.mjs.map +1 -0
  55. package/dist/hooks/usePasswordVisibility.mjs +13 -0
  56. package/dist/hooks/usePasswordVisibility.mjs.map +1 -0
  57. package/dist/index.mjs +80 -0
  58. package/dist/index.mjs.map +1 -1
  59. package/dist/modules/accounts/actions/account-management.action.mjs +163 -0
  60. package/dist/modules/accounts/actions/account-management.action.mjs.map +1 -0
  61. package/dist/modules/accounts/hooks/useAccountManagement.mjs +64 -0
  62. package/dist/modules/accounts/hooks/useAccountManagement.mjs.map +1 -0
  63. package/dist/modules/accounts/services/account.service.mjs +162 -25
  64. package/dist/modules/accounts/services/account.service.mjs.map +1 -1
  65. package/dist/modules/accounts/services/two-factor.service.mjs +77 -0
  66. package/dist/modules/accounts/services/two-factor.service.mjs.map +1 -0
  67. package/dist/server.mjs +36 -0
  68. package/dist/server.mjs.map +1 -1
  69. package/dist/utils/browser/clipboard.mjs +36 -0
  70. package/dist/utils/browser/clipboard.mjs.map +1 -0
  71. package/dist/utils/file/index.mjs +9 -0
  72. package/dist/utils/file/index.mjs.map +1 -0
  73. package/dist/utils/format/masks.mjs +56 -0
  74. package/dist/utils/format/masks.mjs.map +1 -0
  75. package/dist/utils/intl/locales.mjs +12 -0
  76. package/dist/utils/intl/locales.mjs.map +1 -0
  77. package/dist/utils/intl/timezones.mjs +41 -0
  78. package/dist/utils/intl/timezones.mjs.map +1 -0
  79. package/dist/utils/validators/account.mjs +26 -0
  80. package/dist/utils/validators/account.mjs.map +1 -0
  81. package/dist/utils/validators/common.mjs +47 -0
  82. package/dist/utils/validators/common.mjs.map +1 -0
  83. package/package.json +5 -1
  84. package/src/components/account/ConfigurationsMyAccountModal.tsx +147 -0
  85. package/src/components/account/ConfirmGlobalPreferencesModal.tsx +66 -0
  86. package/src/components/account/DisableTwoFactorAuthModal.tsx +86 -0
  87. package/src/components/account/TwoFactorAuthModal.tsx +221 -0
  88. package/src/components/account/constants.ts +29 -0
  89. package/src/components/account/hooks/useChangeEmailForm.tsx +56 -0
  90. package/src/components/account/hooks/useChangePasswordForm.tsx +59 -0
  91. package/src/components/account/hooks/useChangePhoneForm.tsx +69 -0
  92. package/src/components/account/hooks/useConfigurationsModal.ts +40 -0
  93. package/src/components/account/hooks/useDisableTwoFactorAuthForm.tsx +69 -0
  94. package/src/components/account/hooks/useOtpVerification.tsx +116 -0
  95. package/src/components/account/hooks/useTwoFactorAuthForm.tsx +114 -0
  96. package/src/components/account/sections/ChangeEmailModal.tsx +224 -0
  97. package/src/components/account/sections/ChangePasswordSection.tsx +64 -0
  98. package/src/components/account/sections/ChangePhoneModal.tsx +229 -0
  99. package/src/components/account/sections/MyProfileSection.tsx +337 -0
  100. package/src/components/account/sections/PreferencesSection.tsx +277 -0
  101. package/src/components/account/sections/SecuritySection.tsx +160 -0
  102. package/src/components/ui/form/ComboboxField.tsx +154 -0
  103. package/src/components/ui/form/FormField.tsx +130 -0
  104. package/src/components/ui/form/PhoneInput.tsx +98 -0
  105. package/src/components/ui/form/SelectField.tsx +118 -0
  106. package/src/components/ui/form/SwitchOptionFieldWithIcon.tsx +47 -0
  107. package/src/components/ui/form/TextAreaField.tsx +70 -0
  108. package/src/enums/AccountSectionType.ts +6 -0
  109. package/src/hooks/useCountdownTimer.ts +41 -0
  110. package/src/hooks/useIsMobile.ts +19 -0
  111. package/src/hooks/usePasswordVisibility.ts +13 -0
  112. package/src/index.ts +70 -0
  113. package/src/modules/accounts/actions/account-management.action.ts +197 -0
  114. package/src/modules/accounts/hooks/useAccountManagement.ts +79 -0
  115. package/src/modules/accounts/services/account.service.ts +229 -50
  116. package/src/modules/accounts/services/two-factor.service.ts +98 -0
  117. package/src/modules/accounts/types.ts +157 -0
  118. package/src/server.ts +21 -0
  119. package/src/utils/browser/clipboard.ts +34 -0
  120. package/src/utils/file/index.ts +5 -0
  121. package/src/utils/format/masks.ts +52 -0
  122. package/src/utils/intl/locales.ts +15 -0
  123. package/src/utils/intl/timezones.ts +53 -0
  124. package/src/utils/validators/account.ts +36 -0
  125. package/src/utils/validators/common.ts +43 -0
@@ -0,0 +1,337 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef } from 'react';
4
+ import Image from 'next/image';
5
+ import { useForm } from 'react-hook-form';
6
+ import { zodResolver } from '@hookform/resolvers/zod';
7
+ import { toast } from 'sonner';
8
+ import { Upload, Trash } from 'lucide-react';
9
+ import { Button } from '../../ui/buttons/Button';
10
+ import { Separator } from '../../ui/data-display/Separator';
11
+ import { Toast } from '../../ui/feedback/Toast';
12
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/overlay/Dialog';
13
+ import { FormField } from '../../ui/form/FormField';
14
+ import { SelectField } from '../../ui/form/SelectField';
15
+ import PhoneInput from '../../ui/form/PhoneInput';
16
+ import { useAuth } from '../../../providers/auth.provider';
17
+ import { UserProfile } from '../../../modules/users/schema';
18
+ import { myProfileSchema, type MyProfileFormData } from '../../../utils/validators/account';
19
+ import {
20
+ useUpdateAccountUser,
21
+ useDeleteAccount,
22
+ useDeleteAccountUser,
23
+ } from '../../../modules/accounts/hooks/useAccountManagement';
24
+ import { GENDER_OPTIONS } from '../constants';
25
+ import { ChangePhoneModal } from './ChangePhoneModal';
26
+ import { ChangeEmailModal } from './ChangeEmailModal';
27
+
28
+ interface MyProfileSectionProps {
29
+ onClose: () => void;
30
+ storageUrl?: string;
31
+ }
32
+
33
+ export function MyProfileSection({ onClose, storageUrl }: MyProfileSectionProps) {
34
+ const { user, logout } = useAuth();
35
+ const updateAccountUser = useUpdateAccountUser();
36
+ const deleteAccount = useDeleteAccount();
37
+ const deleteAccountUser = useDeleteAccountUser();
38
+ const fileInputRef = useRef<HTMLInputElement>(null);
39
+ const [selectedPhoto, setSelectedPhoto] = useState<File | null>(null);
40
+ const [photoRemoved, setPhotoRemoved] = useState(false);
41
+ const [changePhoneOpen, setChangePhoneOpen] = useState(false);
42
+ const [changeEmailOpen, setChangeEmailOpen] = useState(false);
43
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
44
+
45
+ const isOwner = user?.profile === UserProfile.owner;
46
+ const isDeleting = deleteAccount.isPending || deleteAccountUser.isPending;
47
+
48
+ const form = useForm<MyProfileFormData>({
49
+ resolver: zodResolver(myProfileSchema),
50
+ mode: 'onChange',
51
+ defaultValues: {
52
+ name: user?.name ?? '',
53
+ gender: GENDER_OPTIONS.find((g) => g.apiValue === user?.gender)?.value ?? '',
54
+ },
55
+ });
56
+
57
+ const {
58
+ register,
59
+ handleSubmit,
60
+ watch,
61
+ setValue,
62
+ formState: { errors, isSubmitting },
63
+ } = form;
64
+
65
+ const baseStorageUrl = storageUrl ?? process.env.NEXT_PUBLIC_STORAGE_URL ?? '';
66
+
67
+ const avatarPreview = selectedPhoto
68
+ ? URL.createObjectURL(selectedPhoto)
69
+ : !photoRemoved && user?.photo
70
+ ? `${baseStorageUrl}/${user.photo}`
71
+ : null;
72
+
73
+ const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
74
+ const file = e.target.files?.[0] ?? null;
75
+ if (file) {
76
+ setSelectedPhoto(file);
77
+ setPhotoRemoved(false);
78
+ }
79
+ };
80
+
81
+ const handleRemovePhoto = () => {
82
+ setSelectedPhoto(null);
83
+ setPhotoRemoved(true);
84
+ };
85
+
86
+ const handleDelete = async () => {
87
+ const result = isOwner
88
+ ? await deleteAccount.mutateAsync()
89
+ : await deleteAccountUser.mutateAsync(user!.id);
90
+
91
+ if (!result.success) {
92
+ toast.custom((t) => (
93
+ <Toast
94
+ variant="error"
95
+ message={result.error ?? 'Erro ao excluir. Tente novamente.'}
96
+ toastId={t}
97
+ />
98
+ ));
99
+ setDeleteConfirmOpen(false);
100
+ return;
101
+ }
102
+
103
+ await logout();
104
+ };
105
+
106
+ const onSubmit = async (data: MyProfileFormData) => {
107
+ const result = await updateAccountUser.mutateAsync({
108
+ name: data.name,
109
+ gender: GENDER_OPTIONS.find((g) => g.value === data.gender)?.apiValue,
110
+ ...(selectedPhoto ? { photo: selectedPhoto } : {}),
111
+ ...(photoRemoved ? { photo: '' } : {}),
112
+ });
113
+
114
+ if (!result.success) {
115
+ toast.custom((t) => (
116
+ <Toast variant="error" message={result.error ?? 'Erro ao atualizar perfil'} toastId={t} />
117
+ ));
118
+ return;
119
+ }
120
+
121
+ form.reset(data);
122
+ setSelectedPhoto(null);
123
+ setPhotoRemoved(false);
124
+ toast.custom((t) => (
125
+ <Toast variant="success" message="Perfil atualizado com sucesso" toastId={t} />
126
+ ));
127
+ onClose();
128
+ };
129
+
130
+ return (
131
+ <>
132
+ <form onSubmit={handleSubmit(onSubmit)} className="lg:h-full lg:flex lg:flex-col">
133
+ <input
134
+ ref={fileInputRef}
135
+ type="file"
136
+ accept="image/*"
137
+ className="hidden"
138
+ onChange={handlePhotoChange}
139
+ />
140
+ <div className="absolute inset-0 overflow-y-auto overscroll-contain px-4 pb-20 lg:p-5 lg:static lg:flex-1 lg:min-h-0 lg:overscroll-auto flex flex-col gap-5 lg:gap-6">
141
+ <span className="paragraph-medium-semibold text-gray-950 hidden lg:block">
142
+ Meu perfil
143
+ </span>
144
+ <div className="flex flex-col gap-5">
145
+ <div className="flex items-center gap-4">
146
+ {avatarPreview ? (
147
+ <Image
148
+ src={avatarPreview}
149
+ alt="Foto do usuário"
150
+ width={48}
151
+ height={48}
152
+ className="size-12 rounded-full object-cover cursor-pointer"
153
+ onClick={() => fileInputRef.current?.click()}
154
+ />
155
+ ) : (
156
+ <div
157
+ className="size-12 rounded-full bg-gray-50 border border-dashed border-gray-200 flex items-center justify-center cursor-pointer"
158
+ onClick={() => fileInputRef.current?.click()}
159
+ >
160
+ <Upload size={20} className="text-gray-400" />
161
+ </div>
162
+ )}
163
+ {avatarPreview ? (
164
+ <div className="flex items-center gap-2">
165
+ <Button
166
+ variant="secondary"
167
+ className="w-fit h-8!"
168
+ type="button"
169
+ onClick={() => fileInputRef.current?.click()}
170
+ >
171
+ Alterar foto
172
+ </Button>
173
+ <Button
174
+ type="button"
175
+ variant="ghost"
176
+ className="h-8!"
177
+ onClick={handleRemovePhoto}
178
+ >
179
+ <Trash size={16} className="text-gray-500" />
180
+ </Button>
181
+ </div>
182
+ ) : (
183
+ <Button
184
+ variant="secondary"
185
+ className="w-fit h-8!"
186
+ type="button"
187
+ onClick={() => fileInputRef.current?.click()}
188
+ >
189
+ Alterar foto
190
+ </Button>
191
+ )}
192
+ </div>
193
+
194
+ <div className="flex flex-col gap-8">
195
+ <div className="flex flex-col gap-4">
196
+ <FormField
197
+ label="Nome completo"
198
+ placeholder="Digite seu nome"
199
+ error={!!errors.name}
200
+ errorMessage={errors.name?.message}
201
+ {...register('name')}
202
+ />
203
+ <SelectField
204
+ label="Gênero"
205
+ placeholder="Selecione seu gênero"
206
+ options={GENDER_OPTIONS}
207
+ value={watch('gender')}
208
+ className="h-10!"
209
+ onChange={(value) => setValue('gender', value as string)}
210
+ />
211
+ <div className="relative">
212
+ <PhoneInput
213
+ label="Telefone"
214
+ value={user?.phone ?? ''}
215
+ disabled
216
+ classnameContainer="bg-gray-50 border-0!"
217
+ />
218
+ <button
219
+ type="button"
220
+ className="absolute right-3 top-5.5 h-10 flex items-center paragraph-xsmall-semibold text-gray-950 underline cursor-pointer"
221
+ onClick={() => setChangePhoneOpen(true)}
222
+ >
223
+ Alterar
224
+ </button>
225
+ </div>
226
+ </div>
227
+
228
+ <Separator />
229
+
230
+ <div className="flex flex-col gap-4">
231
+ <span className="paragraph-medium-semibold text-gray-600">Dados de acesso</span>
232
+ <div className="relative">
233
+ <FormField
234
+ label="E-mail"
235
+ value={user?.email ?? ''}
236
+ disabled
237
+ classnameContainer="bg-gray-50 border-0!"
238
+ />
239
+ <button
240
+ type="button"
241
+ className="absolute right-3 top-5.5 h-10 flex items-center paragraph-xsmall-semibold text-gray-950 underline cursor-pointer"
242
+ onClick={() => setChangeEmailOpen(true)}
243
+ >
244
+ Alterar
245
+ </button>
246
+ </div>
247
+ </div>
248
+
249
+ <Separator />
250
+
251
+ <div className="flex flex-col gap-3">
252
+ <span className="paragraph-medium-semibold text-gray-600">Zona de risco</span>
253
+ <div className="flex items-center justify-between">
254
+ <div className="flex flex-col gap-0.5">
255
+ <span className="paragraph-small-medium text-gray-950">
256
+ {isOwner ? 'Excluir conta' : 'Excluir usuário'}
257
+ </span>
258
+ <span className="paragraph-xsmall-medium text-gray-500">
259
+ {isOwner
260
+ ? 'Esta ação é irreversível e apagará todos os dados.'
261
+ : 'Você será removido permanentemente desta conta.'}
262
+ </span>
263
+ </div>
264
+ <Button
265
+ type="button"
266
+ variant="ghost"
267
+ className="h-8! text-red-600 hover:text-red-700 hover:bg-red-50 shrink-0"
268
+ onClick={() => setDeleteConfirmOpen(true)}
269
+ >
270
+ Excluir
271
+ </Button>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ <div className="fixed bottom-0 left-0 right-0 lg:static flex items-center h-20 px-4 lg:px-5 border-t border-gray-200 justify-between bg-white z-10">
279
+ <Button
280
+ variant="secondary"
281
+ className="h-10!"
282
+ type="button"
283
+ disabled={isSubmitting}
284
+ onClick={() => {
285
+ form.reset();
286
+ setSelectedPhoto(null);
287
+ setPhotoRemoved(false);
288
+ onClose();
289
+ }}
290
+ >
291
+ Cancelar
292
+ </Button>
293
+ <Button className="h-10! cursor-pointer" type="submit" disabled={isSubmitting}>
294
+ {isSubmitting ? 'Salvando...' : 'Salvar alterações'}
295
+ </Button>
296
+ </div>
297
+ </form>
298
+
299
+ <ChangePhoneModal open={changePhoneOpen} onClose={() => setChangePhoneOpen(false)} />
300
+ <ChangeEmailModal open={changeEmailOpen} onClose={() => setChangeEmailOpen(false)} />
301
+
302
+ <Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
303
+ <DialogContent className="max-w-sm! gap-5">
304
+ <DialogHeader>
305
+ <DialogTitle className="paragraph-large-semibold text-gray-950">
306
+ {isOwner ? 'Excluir conta' : 'Excluir usuário'}
307
+ </DialogTitle>
308
+ </DialogHeader>
309
+ <p className="paragraph-small-regular text-gray-600">
310
+ {isOwner
311
+ ? 'Tem certeza que deseja excluir sua conta? Todos os dados serão apagados permanentemente e essa ação não pode ser desfeita.'
312
+ : 'Tem certeza que deseja excluir seu usuário? Você perderá acesso a esta conta permanentemente.'}
313
+ </p>
314
+ <div className="flex items-center gap-3 justify-end">
315
+ <Button
316
+ type="button"
317
+ variant="secondary"
318
+ className="h-10!"
319
+ disabled={isDeleting}
320
+ onClick={() => setDeleteConfirmOpen(false)}
321
+ >
322
+ Cancelar
323
+ </Button>
324
+ <Button
325
+ type="button"
326
+ className="h-10! bg-red-600 hover:bg-red-700 text-white border-0!"
327
+ disabled={isDeleting}
328
+ onClick={handleDelete}
329
+ >
330
+ {isDeleting ? 'Excluindo...' : 'Confirmar exclusão'}
331
+ </Button>
332
+ </div>
333
+ </DialogContent>
334
+ </Dialog>
335
+ </>
336
+ );
337
+ }
@@ -0,0 +1,277 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useForm } from 'react-hook-form';
5
+ import { toast } from 'sonner';
6
+ import { Lock, Mail, MessageCircle } from 'lucide-react';
7
+ import { Button } from '../../ui/buttons/Button';
8
+ import { Separator } from '../../ui/data-display/Separator';
9
+ import { Toast } from '../../ui/feedback/Toast';
10
+ import { ComboboxField, type ComboboxOption } from '../../ui/form/ComboboxField';
11
+ import { SelectField } from '../../ui/form/SelectField';
12
+ import SwitchOptionFieldWithIcon from '../../ui/form/SwitchOptionFieldWithIcon';
13
+ import ConfirmGlobalPreferencesModal from '../ConfirmGlobalPreferencesModal';
14
+ import { useAuth } from '../../../providers/auth.provider';
15
+ import { UserProfile } from '../../../modules/users/schema';
16
+ import type { UpdateAccountRequest } from '../../../modules/accounts/types';
17
+ import {
18
+ useUpdateAccountUser,
19
+ useUpdateAccount,
20
+ } from '../../../modules/accounts/hooks/useAccountManagement';
21
+ import {
22
+ LANGUAGE_OPTIONS,
23
+ TIMEZONE_OPTIONS,
24
+ TIME_FORMAT_OPTIONS,
25
+ NOTIFICATION_TYPES,
26
+ } from '../constants';
27
+
28
+ interface PreferencesFormValues {
29
+ language: string;
30
+ currency: string;
31
+ timezone: string;
32
+ timeFormat: string;
33
+ receive_sms: boolean;
34
+ receive_email: boolean;
35
+ }
36
+
37
+ interface PreferencesSectionProps {
38
+ onClose: () => void;
39
+ currencies?: ComboboxOption[];
40
+ }
41
+
42
+ export function PreferencesSection({ onClose, currencies = [] }: PreferencesSectionProps) {
43
+ const { user, account } = useAuth();
44
+ const canEditPreferences =
45
+ user?.profile === UserProfile.owner || user?.profile === UserProfile.admin;
46
+ const updateAccountUser = useUpdateAccountUser();
47
+ const updateAccount = useUpdateAccount();
48
+ const [confirmGlobalOpen, setConfirmGlobalOpen] = useState(false);
49
+ const [pendingSubmit, setPendingSubmit] = useState<{
50
+ data: PreferencesFormValues;
51
+ accountFields: UpdateAccountRequest;
52
+ } | null>(null);
53
+ const [isSaving, setIsSaving] = useState(false);
54
+
55
+ const { watch, setValue, handleSubmit, reset, formState: { dirtyFields } } =
56
+ useForm<PreferencesFormValues>({
57
+ defaultValues: {
58
+ language: '',
59
+ currency: '',
60
+ timezone: '',
61
+ timeFormat: '24h',
62
+ receive_sms: false,
63
+ receive_email: false,
64
+ },
65
+ });
66
+
67
+ useEffect(() => {
68
+ if (!user || !account) return;
69
+ reset({
70
+ language: LANGUAGE_OPTIONS.find(
71
+ (o) => o.apiValue === user.language?.toLowerCase()
72
+ )?.value ?? 'pt-BR',
73
+ currency: account.currency,
74
+ timezone: account.timezone,
75
+ timeFormat: '24h',
76
+ receive_sms: user.receive_sms ?? false,
77
+ receive_email: user.receive_email ?? false,
78
+ });
79
+ }, [user, account, reset]);
80
+
81
+ const receiveSms = watch('receive_sms');
82
+ const receiveEmail = watch('receive_email');
83
+
84
+ const doSave = async (data: PreferencesFormValues, accountFields: UpdateAccountRequest) => {
85
+ setIsSaving(true);
86
+ try {
87
+ const apiLanguage = LANGUAGE_OPTIONS.find((o) => o.value === data.language)?.apiValue ?? data.language.toLowerCase();
88
+ const userResult = await updateAccountUser.mutateAsync({
89
+ language: apiLanguage,
90
+ receive_sms: data.receive_sms,
91
+ receive_email: data.receive_email,
92
+ });
93
+
94
+ if (!userResult.success) {
95
+ toast.custom((t) => (
96
+ <Toast variant="error" message={userResult.error ?? 'Erro ao salvar preferências'} toastId={t} />
97
+ ));
98
+ return;
99
+ }
100
+
101
+ if (canEditPreferences && Object.keys(accountFields).length > 0) {
102
+ const accountResult = await updateAccount.mutateAsync(accountFields);
103
+
104
+ if (!accountResult.success) {
105
+ toast.custom((t) => (
106
+ <Toast variant="error" message={accountResult.error ?? 'Erro ao salvar preferências da conta'} toastId={t} />
107
+ ));
108
+ return;
109
+ }
110
+ }
111
+
112
+ reset(data);
113
+ toast.custom((t) => (
114
+ <Toast variant="success" message="Preferências salvas com sucesso" toastId={t} />
115
+ ));
116
+ onClose();
117
+ } finally {
118
+ setIsSaving(false);
119
+ }
120
+ };
121
+
122
+ const onSubmit = (data: PreferencesFormValues) => {
123
+ const accountFields: UpdateAccountRequest = {
124
+ ...(canEditPreferences && dirtyFields.currency && { currency: data.currency }),
125
+ ...(canEditPreferences && dirtyFields.timezone && { timezone: data.timezone }),
126
+ };
127
+ const globalFieldChanged = Object.keys(accountFields).length > 0;
128
+
129
+ if (globalFieldChanged) {
130
+ setPendingSubmit({ data, accountFields });
131
+ setConfirmGlobalOpen(true);
132
+ } else {
133
+ doSave(data, accountFields);
134
+ }
135
+ };
136
+
137
+ const handleConfirmGlobal = () => {
138
+ setConfirmGlobalOpen(false);
139
+ if (pendingSubmit) {
140
+ doSave(pendingSubmit.data, pendingSubmit.accountFields);
141
+ setPendingSubmit(null);
142
+ }
143
+ };
144
+
145
+ const handleCancelGlobal = () => {
146
+ setConfirmGlobalOpen(false);
147
+ setPendingSubmit(null);
148
+ };
149
+
150
+ return (
151
+ <>
152
+ <form onSubmit={handleSubmit(onSubmit)} className="lg:h-full lg:flex lg:flex-col">
153
+ <div className="absolute inset-0 overflow-y-auto overscroll-contain px-4 pb-36 lg:p-5 lg:static lg:flex-1 lg:min-h-0 lg:overscroll-auto flex flex-col gap-5 lg:gap-6">
154
+ <span className="paragraph-medium-semibold text-gray-950">Preferências</span>
155
+ <div className="flex flex-col gap-8">
156
+ <div className="flex flex-col lg:flex-row items-center gap-3">
157
+ <ComboboxField
158
+ label="Idioma"
159
+ placeholder="Selecione o idioma"
160
+ searchPlaceholder="Buscar idioma..."
161
+ containerClassName="w-full"
162
+ options={LANGUAGE_OPTIONS}
163
+ value={watch('language')}
164
+ onChange={(value) => setValue('language', value, { shouldDirty: true })}
165
+ />
166
+ <ComboboxField
167
+ label="Moeda"
168
+ placeholder="Selecione a moeda"
169
+ searchPlaceholder="Buscar moeda..."
170
+ containerClassName="w-full"
171
+ options={currencies}
172
+ value={watch('currency')}
173
+ onChange={(value) => setValue('currency', value, { shouldDirty: true })}
174
+ icon={!canEditPreferences ? <Lock className="size-4 text-gray-400" /> : undefined}
175
+ disabled={!canEditPreferences}
176
+ />
177
+ </div>
178
+
179
+ <Separator />
180
+
181
+ <div className="flex flex-col gap-4">
182
+ <span className="paragraph-medium-semibold text-gray-600">Exibição hora</span>
183
+ <div className="flex flex-col lg:flex-row items-center gap-3">
184
+ <ComboboxField
185
+ label="Fuso horário"
186
+ placeholder="Selecione o fuso horário"
187
+ searchPlaceholder="Buscar fuso horário..."
188
+ containerClassName="w-full"
189
+ options={TIMEZONE_OPTIONS}
190
+ value={watch('timezone')}
191
+ onChange={(value) => setValue('timezone', value, { shouldDirty: true })}
192
+ icon={!canEditPreferences ? <Lock className="size-4 text-gray-400" /> : undefined}
193
+ disabled={!canEditPreferences}
194
+ />
195
+ <SelectField
196
+ label="Formato hora"
197
+ placeholder="Selecione o formato"
198
+ className="h-10!"
199
+ containerClassName="w-full"
200
+ options={TIME_FORMAT_OPTIONS}
201
+ value={watch('timeFormat')}
202
+ onChange={(value) =>
203
+ setValue('timeFormat', value as string, { shouldDirty: true })
204
+ }
205
+ icon={<Lock className="size-4 text-gray-400" />}
206
+ disabled
207
+ />
208
+ </div>
209
+ </div>
210
+
211
+ <Separator />
212
+
213
+ <div className="flex flex-col gap-4">
214
+ <span className="paragraph-medium-semibold text-gray-600">Notificações</span>
215
+ <div className="flex flex-col gap-3">
216
+ <div className="flex flex-col border border-gray-200 rounded-lg">
217
+ <SwitchOptionFieldWithIcon
218
+ icon={MessageCircle}
219
+ label="WhatsApp"
220
+ description="Receber notificações importantes por WhatsApp"
221
+ checked={receiveSms}
222
+ onCheckedChange={(val: boolean) => setValue('receive_sms', val, { shouldDirty: true })}
223
+ />
224
+ <SwitchOptionFieldWithIcon
225
+ icon={Mail}
226
+ label="E-mail"
227
+ description="Receber notificações e atualizações por email"
228
+ checked={receiveEmail}
229
+ onCheckedChange={(val: boolean) => setValue('receive_email', val, { shouldDirty: true })}
230
+ hideBorderBottom={true}
231
+ />
232
+ </div>
233
+ <div className="flex flex-col gap-4 p-5 pb-6 rounded-lg bg-gray-50">
234
+ <span className="paragraph-small-semibold text-gray-950">
235
+ Tipos de notificações incluem:
236
+ </span>
237
+ <div className="flex flex-col">
238
+ {NOTIFICATION_TYPES.map((type) => (
239
+ <div key={type} className="flex items-center gap-2">
240
+ <div className="size-1 rounded-full bg-gray-600" />
241
+ <span className="paragraph-small-medium text-gray-600">{type}</span>
242
+ </div>
243
+ ))}
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ <div className="fixed bottom-0 left-0 right-0 lg:static flex items-center h-20 px-4 lg:px-5 border-t border-gray-200 justify-between bg-white z-10">
252
+ <Button
253
+ variant="secondary"
254
+ className="h-10!"
255
+ type="button"
256
+ disabled={isSaving}
257
+ onClick={() => {
258
+ reset();
259
+ onClose();
260
+ }}
261
+ >
262
+ Cancelar
263
+ </Button>
264
+ <Button className="h-10!" type="submit" disabled={isSaving}>
265
+ {isSaving ? 'Salvando...' : 'Salvar alterações'}
266
+ </Button>
267
+ </div>
268
+ </form>
269
+
270
+ <ConfirmGlobalPreferencesModal
271
+ open={confirmGlobalOpen}
272
+ onConfirm={handleConfirmGlobal}
273
+ onCancel={handleCancelGlobal}
274
+ />
275
+ </>
276
+ );
277
+ }