11'use client' ;
22
3- import { BuildingsIcon , UploadSimple , UsersIcon } from '@phosphor-icons/react' ;
3+ import {
4+ BuildingsIcon ,
5+ UploadSimpleIcon ,
6+ UsersIcon ,
7+ } from '@phosphor-icons/react' ;
8+ import Image from 'next/image' ;
49import { useRouter } from 'next/navigation' ;
510import { useEffect , useMemo , useRef , useState } from 'react' ;
611import ReactCrop , {
@@ -32,11 +37,19 @@ import { useOrganizations } from '@/hooks/use-organizations';
3237import { getCroppedImage } from '@/lib/canvas-utils' ;
3338import 'react-image-crop/dist/ReactCrop.css' ;
3439
40+ // Top-level regex literals for performance and lint compliance
41+ const SLUG_ALLOWED_REGEX = / ^ [ a - z 0 - 9 - ] + $ / ;
42+ const REGEX_NON_SLUG_NAME_CHARS = / [ ^ a - z 0 - 9 \s - ] / g;
43+ const REGEX_SPACES_TO_DASH = / \s + / g;
44+ const REGEX_MULTI_DASH = / - + / g;
45+ const REGEX_TRIM_DASH = / ^ - + | - + $ / g;
46+ const REGEX_INVALID_SLUG_CHARS = / [ ^ a - z 0 - 9 - ] / g;
47+
3548interface CreateOrganizationData {
3649 name : string ;
3750 slug : string ;
3851 logo : string ;
39- metadata : Record < string , any > ;
52+ metadata : Record < string , unknown > ;
4053}
4154
4255interface CreateOrganizationDialogProps {
@@ -48,7 +61,8 @@ export function CreateOrganizationDialog({
4861 isOpen,
4962 onClose,
5063} : CreateOrganizationDialogProps ) {
51- const { createOrganization, isCreatingOrganization } = useOrganizations ( ) ;
64+ const { createOrganizationAsync, isCreatingOrganization } =
65+ useOrganizations ( ) ;
5266 const router = useRouter ( ) ;
5367
5468 // Form state
@@ -67,17 +81,17 @@ export function CreateOrganizationDialog({
6781 const [ completedCrop , setCompletedCrop ] = useState < PixelCrop > ( ) ;
6882 const [ isCropModalOpen , setIsCropModalOpen ] = useState ( false ) ;
6983 const fileInputRef = useRef < HTMLInputElement > ( null ) ;
70- const imageRef = useRef < HTMLImageElement > ( null ) ;
84+ const imageRef = useRef < HTMLImageElement | null > ( null ) ;
7185
7286 // Slug auto-generation
7387 useEffect ( ( ) => {
7488 if ( ! ( slugManuallyEdited && formData . slug ) ) {
7589 const generatedSlug = formData . name
7690 . toLowerCase ( )
77- . replace ( / [ ^ a - z 0 - 9 \s - ] / g , '' )
78- . replace ( / \s + / g , '-' )
79- . replace ( / - + / g , '-' )
80- . replace ( / ^ - + | - + $ / g , '' ) ;
91+ . replace ( REGEX_NON_SLUG_NAME_CHARS , '' )
92+ . replace ( REGEX_SPACES_TO_DASH , '-' )
93+ . replace ( REGEX_MULTI_DASH , '-' )
94+ . replace ( REGEX_TRIM_DASH , '' ) ;
8195 setFormData ( ( prev ) => ( { ...prev , slug : generatedSlug } ) ) ;
8296 }
8397 } , [ formData . name , formData . slug , slugManuallyEdited ] ) ;
@@ -100,9 +114,9 @@ export function CreateOrganizationDialog({
100114 setSlugManuallyEdited ( true ) ;
101115 const cleanSlug = value
102116 . toLowerCase ( )
103- . replace ( / [ ^ a - z 0 - 9 - ] / g , '' )
104- . replace ( / - + / g , '-' )
105- . replace ( / ^ - + | - + $ / g , '' ) ;
117+ . replace ( REGEX_INVALID_SLUG_CHARS , '' )
118+ . replace ( REGEX_MULTI_DASH , '-' )
119+ . replace ( REGEX_TRIM_DASH , '' ) ;
106120 setFormData ( ( prev ) => ( { ...prev , slug : cleanSlug } ) ) ;
107121 if ( cleanSlug === '' ) {
108122 setSlugManuallyEdited ( false ) ;
@@ -114,25 +128,26 @@ export function CreateOrganizationDialog({
114128 ( ) =>
115129 formData . name . trim ( ) . length >= 2 &&
116130 ( formData . slug || '' ) . trim ( ) . length >= 2 &&
117- / ^ [ a - z 0 - 9 - ] + $ / . test ( formData . slug || '' ) ,
131+ SLUG_ALLOWED_REGEX . test ( formData . slug || '' ) ,
118132 [ formData . name , formData . slug ]
119133 ) ;
120134
121135 // Image crop modal handlers
122- const handleCropModalOpenChange = ( isOpen : boolean ) => {
123- if ( ! isOpen && fileInputRef . current ) {
136+ const handleCropModalOpenChange = ( open : boolean ) => {
137+ if ( ! open && fileInputRef . current ) {
124138 fileInputRef . current . value = '' ;
125139 }
126- if ( ! isOpen ) {
140+ if ( ! open ) {
127141 setImageSrc ( null ) ;
128142 setCrop ( undefined ) ;
129143 setCompletedCrop ( undefined ) ;
130144 }
131- setIsCropModalOpen ( isOpen ) ;
145+ setIsCropModalOpen ( open ) ;
132146 } ;
133147
134- function onImageLoad ( e : React . SyntheticEvent < HTMLImageElement > ) {
135- const { width, height } = e . currentTarget ;
148+ function onImageLoad ( img : HTMLImageElement ) {
149+ const width = img . naturalWidth ;
150+ const height = img . naturalHeight ;
136151 const percentCrop = centerCrop (
137152 makeAspectCrop ( { unit : '%' , width : 90 } , 1 , width , height ) ,
138153 width ,
@@ -146,6 +161,7 @@ export function CreateOrganizationDialog({
146161 width : Math . round ( ( percentCrop . width / 100 ) * width ) ,
147162 height : Math . round ( ( percentCrop . height / 100 ) * height ) ,
148163 } ) ;
164+ imageRef . current = img ;
149165 }
150166
151167 const handleFileChange = ( event : React . ChangeEvent < HTMLInputElement > ) => {
@@ -180,9 +196,8 @@ export function CreateOrganizationDialog({
180196 toast . success ( 'Logo saved successfully!' ) ;
181197 } ;
182198 reader . readAsDataURL ( croppedFile ) ;
183- } catch ( e ) {
199+ } catch {
184200 toast . error ( 'Failed to crop image.' ) ;
185- console . error ( e ) ;
186201 }
187202 } ;
188203
@@ -201,11 +216,11 @@ export function CreateOrganizationDialog({
201216 return ;
202217 }
203218 try {
204- createOrganization ( formData ) ;
219+ await createOrganizationAsync ( formData ) ;
205220 handleClose ( ) ;
206221 router . push ( '/organizations' ) ;
207222 } catch {
208- // Error handled by mutation
223+ // handled by mutation toast
209224 }
210225 } ;
211226
@@ -221,7 +236,6 @@ export function CreateOrganizationDialog({
221236 < div className = "self-start rounded border border-primary/20 bg-primary/10 p-3 sm:self-center" >
222237 < BuildingsIcon
223238 className = "h-6 w-6 text-primary"
224- size = { 16 }
225239 weight = "duotone"
226240 />
227241 </ div >
@@ -245,19 +259,36 @@ export function CreateOrganizationDialog({
245259 >
246260 Organization Name *
247261 </ Label >
248- < Input
249- className = "rounded border-border/50 focus:border-primary/50 focus:ring-primary/20"
250- id = "org-name"
251- maxLength = { 100 }
252- onChange = { ( e ) =>
253- setFormData ( ( prev ) => ( { ...prev , name : e . target . value } ) )
254- }
255- placeholder = "e.g., Acme Corporation"
256- value = { formData . name }
257- />
258- < p className = "text-muted-foreground text-xs" >
259- This is the display name for your organization
260- </ p >
262+ { ( ( ) => {
263+ const isNameValid = formData . name . trim ( ) . length >= 2 ;
264+ return (
265+ < >
266+ < Input
267+ aria-describedby = "org-name-help"
268+ aria-invalid = { ! isNameValid }
269+ className = { `rounded border-border/50 focus:border-primary/50 focus:ring-primary/20 ${
270+ isNameValid ? '' : 'border-destructive'
271+ } `}
272+ id = "org-name"
273+ maxLength = { 100 }
274+ onChange = { ( e ) =>
275+ setFormData ( ( prev ) => ( {
276+ ...prev ,
277+ name : e . target . value ,
278+ } ) )
279+ }
280+ placeholder = "e.g., Acme Corporation"
281+ value = { formData . name }
282+ />
283+ < p
284+ className = "text-muted-foreground text-xs"
285+ id = "org-name-help"
286+ >
287+ This is the display name for your organization
288+ </ p >
289+ </ >
290+ ) ;
291+ } ) ( ) }
261292 </ div >
262293
263294 < div className = "space-y-2" >
@@ -267,18 +298,34 @@ export function CreateOrganizationDialog({
267298 >
268299 Organization Slug *
269300 </ Label >
270- < Input
271- className = "rounded border-border/50 focus:border-primary/50 focus:ring-primary/20"
272- id = "org-slug"
273- maxLength = { 50 }
274- onChange = { ( e ) => handleSlugChange ( e . target . value ) }
275- placeholder = "e.g., acme-corp"
276- value = { formData . slug }
277- />
278- < p className = "text-muted-foreground text-xs" >
279- Used in URLs and must be unique. Only lowercase letters,
280- numbers, and hyphens allowed.
281- </ p >
301+ { ( ( ) => {
302+ const isSlugValid =
303+ SLUG_ALLOWED_REGEX . test ( formData . slug || '' ) &&
304+ ( formData . slug || '' ) . trim ( ) . length >= 2 ;
305+ return (
306+ < >
307+ < Input
308+ aria-describedby = "org-slug-help"
309+ aria-invalid = { ! isSlugValid }
310+ className = { `rounded border-border/50 focus:border-primary/50 focus:ring-primary/20 ${
311+ isSlugValid ? '' : 'border-destructive'
312+ } `}
313+ id = "org-slug"
314+ maxLength = { 50 }
315+ onChange = { ( e ) => handleSlugChange ( e . target . value ) }
316+ placeholder = "e.g., acme-corp"
317+ value = { formData . slug }
318+ />
319+ < p
320+ className = "text-muted-foreground text-xs"
321+ id = "org-slug-help"
322+ >
323+ Used in URLs and must be unique. Only lowercase letters,
324+ numbers, and hyphens allowed.
325+ </ p >
326+ </ >
327+ ) ;
328+ } ) ( ) }
282329 </ div >
283330
284331 < div className = "space-y-2" >
@@ -302,11 +349,18 @@ export function CreateOrganizationDialog({
302349 </ Avatar >
303350 < button
304351 aria-label = "Upload organization logo"
305- className = "absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-50 opacity-0 transition-opacity group-hover:opacity-100"
352+ className = "absolute inset-0 flex cursor-pointer items-center justify-center rounded bg-black bg-opacity-50 opacity-0 transition-opacity group-hover:opacity-100"
306353 onClick = { ( ) => fileInputRef . current ?. click ( ) }
354+ onKeyDown = { ( e ) => {
355+ if ( e . key === 'Enter' || e . key === ' ' ) {
356+ e . preventDefault ( ) ;
357+ fileInputRef . current ?. click ( ) ;
358+ }
359+ } }
307360 type = "button"
308361 >
309- < UploadSimple className = "text-white" size = { 20 } />
362+ < UploadSimpleIcon className = "h-5 w-5 text-white" />
363+ < span className = "sr-only" > Upload organization logo</ span >
310364 </ button >
311365 </ div >
312366 < div className = "min-w-0 flex-1" >
@@ -328,11 +382,7 @@ export function CreateOrganizationDialog({
328382
329383 < div className = "space-y-4" >
330384 < div className = "flex items-center gap-2" >
331- < UsersIcon
332- className = "h-5 w-5 text-primary"
333- size = { 16 }
334- weight = "duotone"
335- />
385+ < UsersIcon className = "h-5 w-5 text-primary" weight = "duotone" />
336386 < Label className = "font-semibold text-base text-foreground" >
337387 Getting Started
338388 </ Label >
@@ -341,19 +391,10 @@ export function CreateOrganizationDialog({
341391 < p className = "text-muted-foreground text-sm" >
342392 After creating your organization, you'll be able to:
343393 </ p >
344- < ul className = "mt-2 space-y-1 text-muted-foreground text-sm" >
345- < li className = "flex items-start gap-2" >
346- < span className = "mt-0.5 text-primary" > •</ span >
347- Invite team members with different roles
348- </ li >
349- < li className = "flex items-start gap-2" >
350- < span className = "mt-0.5 text-primary" > •</ span >
351- Share websites and analytics data
352- </ li >
353- < li className = "flex items-start gap-2" >
354- < span className = "mt-0.5 text-primary" > •</ span >
355- Manage organization settings and permissions
356- </ li >
394+ < ul className = "mt-2 list-disc space-y-1 pl-5 text-muted-foreground text-sm" >
395+ < li > Invite team members with different roles</ li >
396+ < li > Share websites and analytics data</ li >
397+ < li > Manage organization settings and permissions</ li >
357398 </ ul >
358399 </ div >
359400 </ div >
@@ -372,6 +413,7 @@ export function CreateOrganizationDialog({
372413 className = "relative order-1 rounded sm:order-2"
373414 disabled = { ! isFormValid || isCreatingOrganization }
374415 onClick = { handleSubmit }
416+ type = "button"
375417 >
376418 { isCreatingOrganization && (
377419 < div className = "absolute left-3" >
@@ -392,25 +434,26 @@ export function CreateOrganizationDialog({
392434 < Dialog onOpenChange = { handleCropModalOpenChange } open = { isCropModalOpen } >
393435 < DialogContent className = "max-h-[95vh] max-w-[95vw] overflow-auto" >
394436 < DialogHeader >
395- < DialogTitle > Crop your organization logo</ DialogTitle >
437+ < DialogTitle > Crop organization logo</ DialogTitle >
396438 </ DialogHeader >
397439 { imageSrc && (
398440 < div className = "flex justify-center" >
399441 < ReactCrop
400442 aspect = { 1 }
401- circularCrop = { true }
443+ circularCrop
402444 crop = { crop }
403445 onChange = { ( pixelCrop , percentCrop ) => {
404446 setCrop ( percentCrop ) ;
405447 setCompletedCrop ( pixelCrop ) ;
406448 } }
407449 >
408- < img
450+ < Image
409451 alt = "Crop preview"
410452 className = "max-h-[60vh] max-w-full object-contain"
411- onLoad = { onImageLoad }
412- ref = { imageRef }
413- src = { imageSrc }
453+ height = { 600 }
454+ onLoadingComplete = { onImageLoad }
455+ src = { imageSrc as string }
456+ width = { 800 }
414457 />
415458 </ ReactCrop >
416459 </ div >
@@ -419,6 +462,7 @@ export function CreateOrganizationDialog({
419462 < Button
420463 className = "w-full sm:w-auto"
421464 onClick = { ( ) => handleCropModalOpenChange ( false ) }
465+ type = "button"
422466 variant = "outline"
423467 >
424468 Cancel
@@ -427,6 +471,7 @@ export function CreateOrganizationDialog({
427471 className = "w-full sm:w-auto"
428472 disabled = { ! ( imageSrc && completedCrop ) }
429473 onClick = { handleCropSave }
474+ type = "button"
430475 >
431476 Save Logo
432477 </ Button >
0 commit comments