11import FederatedSideMenu from "@/federated-modules/sideMenu/FederatedSideMenu" ;
22import { TopMenu } from "@/shared/components/topMenu" ;
3- import logoWrap from "@/shared/images/logo-wrap.svg" ;
43import { UserRole , api } from "@/shared/lib/api/client" ;
54import { t } from "@lingui/core/macro" ;
65import { Trans } from "@lingui/react/macro" ;
76import { AppLayout } from "@repo/ui/components/AppLayout" ;
87import { Breadcrumb } from "@repo/ui/components/Breadcrumbs" ;
98import { Button } from "@repo/ui/components/Button" ;
109import { Form } from "@repo/ui/components/Form" ;
10+ import { Menu , MenuItem , MenuSeparator , MenuTrigger } from "@repo/ui/components/Menu" ;
11+ import { TenantLogo } from "@repo/ui/components/TenantLogo" ;
1112import { TextField } from "@repo/ui/components/TextField" ;
1213import { toastQueue } from "@repo/ui/components/Toast" ;
1314import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter" ;
15+ import type { FileUploadMutation } from "@repo/ui/types/FileUpload" ;
16+ import { useQueryClient } from "@tanstack/react-query" ;
1417import { createFileRoute } from "@tanstack/react-router" ;
15- import { Trash2 } from "lucide-react" ;
16- import { useEffect , useState } from "react" ;
17- import { Separator } from "react-aria-components" ;
18+ import { CameraIcon , Trash2 , Trash2Icon } from "lucide-react" ;
19+ import type React from "react" ;
20+ import { useCallback , useEffect , useRef , useState } from "react" ;
21+ import { FileTrigger , Label , Separator } from "react-aria-components" ;
1822import DeleteAccountConfirmation from "./-components/DeleteAccountConfirmation" ;
1923
2024export const Route = createFileRoute ( "/admin/account/" ) ( {
2125 component : AccountSettings
2226} ) ;
2327
28+ const MAX_FILE_SIZE = 1024 * 1024 ; // 1MB in bytes
29+ const ALLOWED_FILE_TYPES = [ "image/jpeg" , "image/png" , "image/gif" , "image/webp" , "image/svg+xml" ] ; // Align with backend
30+
31+ // Helper function for file validation
32+ function validateLogoFile ( file : File ) : boolean {
33+ if ( ! ALLOWED_FILE_TYPES . includes ( file . type ) ) {
34+ alert ( t `Please select a JPEG, PNG, GIF, WebP, or SVG image.` ) ;
35+ return false ;
36+ }
37+
38+ if ( file . size > MAX_FILE_SIZE ) {
39+ alert ( t `Image must be smaller than 1 MB.` ) ;
40+ return false ;
41+ }
42+
43+ return true ;
44+ }
45+
46+ // Custom hook for managing logo state
47+ function useLogoManagement (
48+ updateTenantLogoMutation : FileUploadMutation ,
49+ removeTenantLogoMutation : { mutateAsync : ( params : Record < string , never > ) => Promise < unknown > } ,
50+ refetchTenant : ( ) => void ,
51+ queryClient : ReturnType < typeof useQueryClient > ,
52+ logoFileInputRef : React . RefObject < HTMLInputElement | null >
53+ ) {
54+ const [ logoPreviewUrl , setLogoPreviewUrl ] = useState < string | null > ( null ) ;
55+ const [ logoMenuOpen , setLogoMenuOpen ] = useState ( false ) ;
56+ const [ shouldClearInput , setShouldClearInput ] = useState ( false ) ;
57+
58+ // Handle clearing the input when removal is successful
59+ useEffect ( ( ) => {
60+ if ( shouldClearInput && logoFileInputRef . current ) {
61+ logoFileInputRef . current . value = "" ;
62+ setShouldClearInput ( false ) ;
63+ }
64+ } , [ shouldClearInput , logoFileInputRef ] ) ;
65+
66+ const handleLogoUpload = useCallback (
67+ async ( files : FileList | null ) => {
68+ const file = files ?. [ 0 ] ;
69+ if ( ! file || ! validateLogoFile ( file ) ) {
70+ return ;
71+ }
72+
73+ // Create preview
74+ const objectUrl = URL . createObjectURL ( file ) ;
75+ setLogoPreviewUrl ( objectUrl ) ;
76+
77+ // Upload immediately
78+ const formData = new FormData ( ) ;
79+ formData . append ( "file" , file ) ;
80+ await updateTenantLogoMutation . mutateAsync ( { body : formData } ) ;
81+
82+ // Clean up preview after successful upload
83+ URL . revokeObjectURL ( objectUrl ) ;
84+ setLogoPreviewUrl ( null ) ;
85+
86+ // Invalidate all queries to refresh UI
87+ await queryClient . invalidateQueries ( ) ;
88+ refetchTenant ( ) ;
89+
90+ toastQueue . add ( {
91+ title : t `Success` ,
92+ description : t `Logo uploaded successfully` ,
93+ variant : "success"
94+ } ) ;
95+ } ,
96+ [ updateTenantLogoMutation , refetchTenant , queryClient ]
97+ ) ;
98+
99+ const handleLogoRemoval = useCallback ( async ( ) => {
100+ await removeTenantLogoMutation . mutateAsync ( { } ) ;
101+
102+ // Invalidate all queries to refresh UI
103+ await queryClient . invalidateQueries ( ) ;
104+ refetchTenant ( ) ;
105+
106+ // Trigger input clearing via state
107+ setShouldClearInput ( true ) ;
108+
109+ toastQueue . add ( {
110+ title : t `Success` ,
111+ description : t `Logo removed successfully` ,
112+ variant : "success"
113+ } ) ;
114+ } , [ removeTenantLogoMutation , refetchTenant , queryClient ] ) ;
115+
116+ const cleanupLogoPreview = useCallback ( ( ) => {
117+ if ( logoPreviewUrl ) {
118+ URL . revokeObjectURL ( logoPreviewUrl ) ;
119+ setLogoPreviewUrl ( null ) ;
120+ }
121+ } , [ logoPreviewUrl ] ) ;
122+
123+ return {
124+ logoPreviewUrl,
125+ logoMenuOpen,
126+ setLogoMenuOpen,
127+ handleLogoUpload,
128+ handleLogoRemoval,
129+ cleanupLogoPreview
130+ } ;
131+ }
132+
133+ // Logo management component
134+ function LogoSection ( {
135+ tenant,
136+ logoPreviewUrl,
137+ logoMenuOpen,
138+ setLogoMenuOpen,
139+ handleLogoUpload,
140+ handleLogoRemoval,
141+ logoFileInputRef,
142+ isOwner
143+ } : Readonly < {
144+ tenant : { logoUrl ?: string | null ; name ?: string } | null | undefined ;
145+ logoPreviewUrl : string | null ;
146+ logoMenuOpen : boolean ;
147+ setLogoMenuOpen : ( open : boolean ) => void ;
148+ handleLogoUpload : ( files : FileList | null ) => void ;
149+ handleLogoRemoval : ( ) => void ;
150+ logoFileInputRef : React . RefObject < HTMLInputElement | null > ;
151+ isOwner : boolean ;
152+ } > ) {
153+ return (
154+ < >
155+ < FileTrigger
156+ ref = { logoFileInputRef }
157+ onSelect = { ( files ) => {
158+ setLogoMenuOpen ( false ) ;
159+ handleLogoUpload ( files ) ;
160+ } }
161+ acceptedFileTypes = { ALLOWED_FILE_TYPES }
162+ />
163+
164+ < Label >
165+ < Trans > Logo</ Trans >
166+ </ Label >
167+
168+ < MenuTrigger isOpen = { logoMenuOpen } onOpenChange = { setLogoMenuOpen } >
169+ < Button variant = "icon" className = "h-16 w-16 rounded-md" aria-label = { t `Change logo` } isDisabled = { ! isOwner } >
170+ { tenant ?. logoUrl || logoPreviewUrl ? (
171+ < img
172+ src = { logoPreviewUrl ?? tenant ?. logoUrl ?? "" }
173+ className = "h-full w-full rounded-md object-contain"
174+ alt = { t `Logo` }
175+ />
176+ ) : (
177+ < TenantLogo
178+ key = { tenant ?. logoUrl || "no-logo" }
179+ logoUrl = { null }
180+ tenantName = { tenant ?. name ?? "" }
181+ size = "lg"
182+ isRound = { false }
183+ className = "h-full w-full"
184+ />
185+ ) }
186+ </ Button >
187+ < Menu >
188+ < MenuItem
189+ onAction = { ( ) => {
190+ logoFileInputRef . current ?. click ( ) ;
191+ } }
192+ >
193+ < CameraIcon className = "h-4 w-4" />
194+ < Trans > Upload logo</ Trans >
195+ </ MenuItem >
196+ { ( tenant ?. logoUrl || logoPreviewUrl ) && (
197+ < >
198+ < MenuSeparator />
199+ < MenuItem
200+ onAction = { ( ) => {
201+ setLogoMenuOpen ( false ) ;
202+ handleLogoRemoval ( ) ;
203+ } }
204+ >
205+ < Trash2Icon className = "h-4 w-4 text-destructive" />
206+ < span className = "text-destructive" >
207+ < Trans > Remove logo</ Trans >
208+ </ span >
209+ </ MenuItem >
210+ </ >
211+ ) }
212+ </ Menu >
213+ </ MenuTrigger >
214+ </ >
215+ ) ;
216+ }
217+
218+ // Danger zone component
219+ function DangerZone ( { setIsDeleteModalOpen } : { setIsDeleteModalOpen : ( open : boolean ) => void } ) {
220+ return (
221+ < div className = "mt-6 flex flex-col gap-4" >
222+ < h2 >
223+ < Trans > Danger zone</ Trans >
224+ </ h2 >
225+ < Separator />
226+ < div className = "flex flex-col gap-4" >
227+ < p >
228+ < Trans > Delete your account and all data. This action is irreversible—proceed with caution.</ Trans >
229+ </ p >
230+
231+ < Button variant = "destructive" onPress = { ( ) => setIsDeleteModalOpen ( true ) } className = "w-fit" >
232+ < Trash2 />
233+ < Trans > Delete account</ Trans >
234+ </ Button >
235+ </ div >
236+ </ div >
237+ ) ;
238+ }
239+
24240export function AccountSettings ( ) {
25241 const [ isDeleteModalOpen , setIsDeleteModalOpen ] = useState ( false ) ;
26- const { data : tenant , isLoading : tenantLoading } = api . useQuery ( "get" , "/api/account-management/tenants/current" ) ;
242+ const logoFileInputRef = useRef < HTMLInputElement > ( null ) ;
243+ const queryClient = useQueryClient ( ) ;
244+
245+ const {
246+ data : tenant ,
247+ isLoading : tenantLoading ,
248+ refetch : refetchTenant
249+ } = api . useQuery ( "get" , "/api/account-management/tenants/current" ) ;
27250 const { data : currentUser , isLoading : userLoading } = api . useQuery ( "get" , "/api/account-management/users/me" ) ;
28251 const updateCurrentTenantMutation = api . useMutation ( "put" , "/api/account-management/tenants/current" ) ;
252+ const updateTenantLogoMutation = api . useMutation ( "post" , "/api/account-management/tenants/current/update-logo" ) ;
253+ const removeTenantLogoMutation = api . useMutation ( "delete" , "/api/account-management/tenants/current/remove-logo" ) ;
254+
255+ const { logoPreviewUrl, logoMenuOpen, setLogoMenuOpen, handleLogoUpload, handleLogoRemoval } = useLogoManagement (
256+ updateTenantLogoMutation as unknown as FileUploadMutation ,
257+ removeTenantLogoMutation ,
258+ refetchTenant ,
259+ queryClient ,
260+ logoFileInputRef
261+ ) ;
29262
30263 const isOwner = currentUser ?. role === UserRole . Owner ;
31264
32265 useEffect ( ( ) => {
33266 if ( updateCurrentTenantMutation . isSuccess ) {
34267 toastQueue . add ( {
35268 title : t `Success` ,
36- description : t `Account updated successfully` ,
269+ description : t `Account name updated successfully` ,
37270 variant : "success"
38271 } ) ;
272+ refetchTenant ( ) ;
39273 }
40- } , [ updateCurrentTenantMutation . isSuccess ] ) ;
274+ } , [ updateCurrentTenantMutation . isSuccess , refetchTenant ] ) ;
41275
42276 if ( tenantLoading || userLoading ) {
43277 return null ;
@@ -72,9 +306,17 @@ export function AccountSettings() {
72306 </ h2 >
73307 < Separator />
74308
75- < Trans > Logo</ Trans >
309+ < LogoSection
310+ tenant = { tenant }
311+ logoPreviewUrl = { logoPreviewUrl }
312+ logoMenuOpen = { logoMenuOpen }
313+ setLogoMenuOpen = { setLogoMenuOpen }
314+ handleLogoUpload = { handleLogoUpload }
315+ handleLogoRemoval = { handleLogoRemoval }
316+ logoFileInputRef = { logoFileInputRef }
317+ isOwner = { isOwner }
318+ />
76319
77- < img src = { logoWrap } alt = { t `Logo` } className = "max-h-16 max-w-64" />
78320 < TextField
79321 isRequired = { true }
80322 name = "name"
@@ -92,24 +334,7 @@ export function AccountSettings() {
92334 ) }
93335 </ Form >
94336
95- { isOwner && (
96- < div className = "mt-6 flex flex-col gap-4" >
97- < h2 >
98- < Trans > Danger zone</ Trans >
99- </ h2 >
100- < Separator />
101- < div className = "flex flex-col gap-4" >
102- < p >
103- < Trans > Delete your account and all data. This action is irreversible—proceed with caution.</ Trans >
104- </ p >
105-
106- < Button variant = "destructive" onPress = { ( ) => setIsDeleteModalOpen ( true ) } className = "w-fit" >
107- < Trash2 />
108- < Trans > Delete account</ Trans >
109- </ Button >
110- </ div >
111- </ div >
112- ) }
337+ { isOwner && < DangerZone setIsDeleteModalOpen = { setIsDeleteModalOpen } /> }
113338 </ AppLayout >
114339
115340 < DeleteAccountConfirmation isOpen = { isDeleteModalOpen } onOpenChange = { setIsDeleteModalOpen } />
0 commit comments