@brightweblabs/module-orgs 0.4.12 → 0.5.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brightweblabs/module-orgs",
3
3
  "private": false,
4
- "version": "0.4.12",
4
+ "version": "0.5.0",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "files": [
@@ -18,13 +18,24 @@
18
18
  },
19
19
  "exports": {
20
20
  ".": "./src/index.ts",
21
+ "./ui": "./src/ui/index.ts",
21
22
  "./registration": "./src/registration.ts"
22
23
  },
23
24
  "dependencies": {
24
25
  "@supabase/supabase-js": "^2.110.8",
26
+ "lucide-react": "^1.26.0",
25
27
  "server-only": "^0.0.1",
26
- "@brightweblabs/app-shell": "0.14.4",
28
+ "@brightweblabs/app-shell": "0.15.0",
27
29
  "@brightweblabs/core-auth": "0.10.8",
28
- "@brightweblabs/infra": "0.7.0"
30
+ "@brightweblabs/infra": "0.7.0",
31
+ "@brightweblabs/ui": "1.5.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react": "^19.0.0",
35
+ "react-dom": "^19.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "react": "19.2.4",
39
+ "react-dom": "19.2.4"
29
40
  }
30
41
  }
@@ -0,0 +1 @@
1
+ export * from "./organization-create-sheet";
@@ -0,0 +1,299 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useState, type FormEvent } from "react";
4
+ import { Building2, Plus, Save } from "lucide-react";
5
+ import {
6
+ AppSheetBody,
7
+ AppSheetFooter,
8
+ AppSheetHeader,
9
+ SheetSection,
10
+ SheetSelect,
11
+ sheetEditControlClassName,
12
+ sheetFieldLabelClassName,
13
+ sheetShellClassName,
14
+ } from "@brightweblabs/app-shell";
15
+ import {
16
+ AlertDialog,
17
+ AlertDialogAction,
18
+ AlertDialogCancel,
19
+ AlertDialogContent,
20
+ AlertDialogDescription,
21
+ AlertDialogFooter,
22
+ AlertDialogHeader,
23
+ AlertDialogTitle,
24
+ Button,
25
+ Field,
26
+ FieldContent,
27
+ FieldGroup,
28
+ FieldLabel,
29
+ Input,
30
+ Sheet,
31
+ SheetContent,
32
+ } from "@brightweblabs/ui";
33
+
34
+ export type OrganizationCreateInvitation = {
35
+ email: string;
36
+ role: "admin" | "member";
37
+ };
38
+
39
+ export type OrganizationCreateSheetInput = {
40
+ name: string;
41
+ industry?: string | null;
42
+ companySize?: string | null;
43
+ budgetRange?: string | null;
44
+ websiteUrl?: string | null;
45
+ addressLine1?: string | null;
46
+ addressLine2?: string | null;
47
+ zipCode?: string | null;
48
+ country?: string | null;
49
+ taxIdentifierValue?: string | null;
50
+ invitations: OrganizationCreateInvitation[];
51
+ };
52
+
53
+ export type OrganizationCreateSheetDictionary = {
54
+ eyebrow: string;
55
+ title: string;
56
+ description: string;
57
+ identity: string;
58
+ name: string;
59
+ namePlaceholder: string;
60
+ industry: string;
61
+ selectIndustry: string;
62
+ website: string;
63
+ websitePlaceholder: string;
64
+ taxIdentifier: string;
65
+ taxIdentifierPlaceholder: string;
66
+ location: string;
67
+ address: string;
68
+ addressPlaceholder: string;
69
+ addressLine2: string;
70
+ addressLine2Placeholder: string;
71
+ zipCode: string;
72
+ country: string;
73
+ profile: string;
74
+ companySize: string;
75
+ budgetRange: string;
76
+ members: string;
77
+ emailPlaceholder: string;
78
+ member: string;
79
+ admin: string;
80
+ add: string;
81
+ remove: string;
82
+ optionalInvites: string;
83
+ inviteHint: string;
84
+ invalidEmail: string;
85
+ create: string;
86
+ creating: string;
87
+ cancel: string;
88
+ createError: string;
89
+ discardTitle: string;
90
+ discardDescription: string;
91
+ continueEditing: string;
92
+ discard: string;
93
+ };
94
+
95
+ export const defaultOrganizationCreateSheetDictionary: OrganizationCreateSheetDictionary = {
96
+ eyebrow: "A criar",
97
+ title: "Nova organização",
98
+ description: "Cria a organização e, opcionalmente, convida os seus membros.",
99
+ identity: "Identificação",
100
+ name: "Nome",
101
+ namePlaceholder: "Empresa Verde, Lda.",
102
+ industry: "Setor",
103
+ selectIndustry: "Selecionar setor",
104
+ website: "Website",
105
+ websitePlaceholder: "https://empresa.pt",
106
+ taxIdentifier: "NIF",
107
+ taxIdentifierPlaceholder: "123456789",
108
+ location: "Localização",
109
+ address: "Morada",
110
+ addressPlaceholder: "Rua Exemplo, 120",
111
+ addressLine2: "Complemento",
112
+ addressLine2Placeholder: "Andar, porta, edifício…",
113
+ zipCode: "Código postal",
114
+ country: "País",
115
+ profile: "Perfil",
116
+ companySize: "Dimensão",
117
+ budgetRange: "Orçamento",
118
+ members: "Membros",
119
+ emailPlaceholder: "email@empresa.pt",
120
+ member: "Membro",
121
+ admin: "Administrador",
122
+ add: "Adicionar",
123
+ remove: "Remover",
124
+ optionalInvites: "Os convites são opcionais.",
125
+ inviteHint: "Os membros existentes são adicionados diretamente; os restantes recebem um convite.",
126
+ invalidEmail: "Introduz um email válido.",
127
+ create: "Criar organização",
128
+ creating: "A criar…",
129
+ cancel: "Cancelar",
130
+ createError: "Não foi possível criar a organização.",
131
+ discardTitle: "Descartar alterações?",
132
+ discardDescription: "Os dados desta nova organização ainda não foram guardados.",
133
+ continueEditing: "Continuar a editar",
134
+ discard: "Descartar",
135
+ };
136
+
137
+ const industries = ["Agricultura", "Alimentação e Bebidas", "Construção", "Educação", "Energia", "Financeiro", "Imobiliário", "Indústria Transformadora", "Logística e Transportes", "Retalho", "Saúde", "Tecnologia", "Turismo e Hotelaria"];
138
+ const companySizes = ["1", "1-10", "10-50", "50-100", "100++"];
139
+ const budgetRanges = ["Até 5.000 €", "5.000 € - 10.000 €", "10.000 € - 25.000 €", "25.000 € - 50.000 €", "50.000 € - 100.000 €", "100.000 €+"];
140
+
141
+ const emptyValue: Omit<OrganizationCreateSheetInput, "invitations"> = {
142
+ name: "",
143
+ industry: "",
144
+ companySize: "",
145
+ budgetRange: "",
146
+ websiteUrl: "",
147
+ addressLine1: "",
148
+ addressLine2: "",
149
+ zipCode: "",
150
+ country: "",
151
+ taxIdentifierValue: "",
152
+ };
153
+
154
+ export function addOrganizationCreateInvitation(
155
+ current: OrganizationCreateInvitation[],
156
+ draft: OrganizationCreateInvitation,
157
+ ) {
158
+ const email = draft.email.trim().toLowerCase();
159
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { valid: false, invitations: current };
160
+ if (current.some((item) => item.email === email)) return { valid: true, invitations: current };
161
+ return { valid: true, invitations: [...current, { email, role: draft.role }] };
162
+ }
163
+
164
+ export function buildOrganizationCreateSheetInput(
165
+ value: Omit<OrganizationCreateSheetInput, "invitations">,
166
+ invitations: OrganizationCreateInvitation[],
167
+ ): OrganizationCreateSheetInput {
168
+ return { ...value, invitations };
169
+ }
170
+
171
+ export type OrganizationCreateSheetProps = {
172
+ open: boolean;
173
+ onOpenChange: (open: boolean) => void;
174
+ onSubmit: (input: OrganizationCreateSheetInput) => Promise<void> | void;
175
+ dictionary?: Partial<OrganizationCreateSheetDictionary>;
176
+ };
177
+
178
+ export function OrganizationCreateSheet({ open, onOpenChange, onSubmit, dictionary: dictionaryOverrides }: OrganizationCreateSheetProps) {
179
+ const dictionary = { ...defaultOrganizationCreateSheetDictionary, ...dictionaryOverrides };
180
+ const fieldId = useId();
181
+ const [value, setValue] = useState(emptyValue);
182
+ const [inviteDraft, setInviteDraft] = useState<OrganizationCreateInvitation>({ email: "", role: "member" });
183
+ const [invitations, setInvitations] = useState<OrganizationCreateInvitation[]>([]);
184
+ const [saving, setSaving] = useState(false);
185
+ const [operationError, setOperationError] = useState<string | null>(null);
186
+ const [discardOpen, setDiscardOpen] = useState(false);
187
+
188
+ useEffect(() => {
189
+ if (!open) return;
190
+ setValue(emptyValue);
191
+ setInviteDraft({ email: "", role: "member" });
192
+ setInvitations([]);
193
+ setOperationError(null);
194
+ }, [open]);
195
+
196
+ const addInvitation = () => {
197
+ const result = addOrganizationCreateInvitation(invitations, inviteDraft);
198
+ if (!result.valid) {
199
+ setOperationError(dictionary.invalidEmail);
200
+ return;
201
+ }
202
+ setOperationError(null);
203
+ setInvitations(result.invitations);
204
+ setInviteDraft({ email: "", role: "member" });
205
+ };
206
+
207
+ const submit = async (event: FormEvent) => {
208
+ event.preventDefault();
209
+ if (saving || !value.name.trim()) return;
210
+ setOperationError(null);
211
+ setSaving(true);
212
+ try {
213
+ await onSubmit(buildOrganizationCreateSheetInput(value, invitations));
214
+ onOpenChange(false);
215
+ } catch (error) {
216
+ setOperationError(error instanceof Error ? error.message : dictionary.createError);
217
+ } finally {
218
+ setSaving(false);
219
+ }
220
+ };
221
+
222
+ const hasDraft = Object.values(value).some((field) => Boolean(field?.trim()))
223
+ || Boolean(inviteDraft.email.trim())
224
+ || invitations.length > 0;
225
+ const requestClose = () => {
226
+ if (hasDraft) setDiscardOpen(true);
227
+ else onOpenChange(false);
228
+ };
229
+
230
+ return (
231
+ <>
232
+ <Sheet open={open} onOpenChange={(next) => { if (next) onOpenChange(true); }}>
233
+ <SheetContent className={sheetShellClassName} showCloseButton={false} onInteractOutside={(event) => event.preventDefault()}>
234
+ <form onSubmit={submit} className="flex min-h-0 flex-1 flex-col gap-0">
235
+ <AppSheetHeader icon={Building2} editing eyebrow={dictionary.eyebrow} title={dictionary.title} description={dictionary.description} />
236
+ <AppSheetBody>
237
+ {operationError ? <p role="alert" className="mx-4 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-meta text-destructive">{operationError}</p> : null}
238
+ <SheetSection title={dictionary.identity} editing>
239
+ <FieldGroup className="gap-0 px-0 py-1">
240
+ <Field className="gap-1.5 px-4 py-2"><FieldLabel htmlFor={`${fieldId}-name`} className={sheetFieldLabelClassName}>{dictionary.name}</FieldLabel><FieldContent><Input id={`${fieldId}-name`} autoComplete="organization" value={value.name} onChange={(event) => setValue({ ...value, name: event.target.value })} placeholder={dictionary.namePlaceholder} className={`${sheetEditControlClassName} mt-1.5`} /></FieldContent></Field>
241
+ <Field className="gap-1.5 px-4 py-2"><FieldLabel className={sheetFieldLabelClassName}>{dictionary.industry}</FieldLabel><FieldContent><SheetSelect className="mt-1.5" value={value.industry ?? ""} onValueChange={(industry) => setValue({ ...value, industry })} options={[{ value: "", label: dictionary.selectIndustry }, ...industries.map((industry) => ({ value: industry, label: industry }))]} /></FieldContent></Field>
242
+ <Field className="gap-1.5 px-4 py-2"><FieldLabel htmlFor={`${fieldId}-website`} className={sheetFieldLabelClassName}>{dictionary.website}</FieldLabel><FieldContent><Input id={`${fieldId}-website`} type="url" autoComplete="url" value={value.websiteUrl ?? ""} onChange={(event) => setValue({ ...value, websiteUrl: event.target.value })} placeholder={dictionary.websitePlaceholder} className={`${sheetEditControlClassName} mt-1.5`} /></FieldContent></Field>
243
+ <Field className="gap-1.5 px-4 py-2"><FieldLabel htmlFor={`${fieldId}-tax`} className={sheetFieldLabelClassName}>{dictionary.taxIdentifier}</FieldLabel><FieldContent><Input id={`${fieldId}-tax`} inputMode="numeric" value={value.taxIdentifierValue ?? ""} onChange={(event) => setValue({ ...value, taxIdentifierValue: event.target.value.replace(/\D/g, "") })} placeholder={dictionary.taxIdentifierPlaceholder} className={`${sheetEditControlClassName} mt-1.5`} /></FieldContent></Field>
244
+ </FieldGroup>
245
+ </SheetSection>
246
+ <SheetSection title={dictionary.location} editing>
247
+ <FieldGroup className="grid gap-3 px-4 py-3 sm:grid-cols-2">
248
+ <Field className="sm:col-span-2"><FieldLabel htmlFor={`${fieldId}-address`} className={sheetFieldLabelClassName}>{dictionary.address}</FieldLabel><Input id={`${fieldId}-address`} autoComplete="street-address" value={value.addressLine1 ?? ""} onChange={(event) => setValue({ ...value, addressLine1: event.target.value })} placeholder={dictionary.addressPlaceholder} className={`${sheetEditControlClassName} mt-1.5`} /></Field>
249
+ <Field className="sm:col-span-2"><FieldLabel htmlFor={`${fieldId}-address-2`} className={sheetFieldLabelClassName}>{dictionary.addressLine2}</FieldLabel><Input id={`${fieldId}-address-2`} value={value.addressLine2 ?? ""} onChange={(event) => setValue({ ...value, addressLine2: event.target.value })} placeholder={dictionary.addressLine2Placeholder} className={`${sheetEditControlClassName} mt-1.5`} /></Field>
250
+ <Field><FieldLabel htmlFor={`${fieldId}-zip`} className={sheetFieldLabelClassName}>{dictionary.zipCode}</FieldLabel><Input id={`${fieldId}-zip`} autoComplete="postal-code" value={value.zipCode ?? ""} onChange={(event) => setValue({ ...value, zipCode: event.target.value })} className={`${sheetEditControlClassName} mt-1.5`} /></Field>
251
+ <Field><FieldLabel htmlFor={`${fieldId}-country`} className={sheetFieldLabelClassName}>{dictionary.country}</FieldLabel><Input id={`${fieldId}-country`} autoComplete="country-name" value={value.country ?? ""} onChange={(event) => setValue({ ...value, country: event.target.value })} className={`${sheetEditControlClassName} mt-1.5`} /></Field>
252
+ </FieldGroup>
253
+ </SheetSection>
254
+ <SheetSection title={dictionary.profile} editing>
255
+ <FieldGroup className="grid gap-3 px-4 py-3 sm:grid-cols-2">
256
+ <Field><FieldLabel className={sheetFieldLabelClassName}>{dictionary.companySize}</FieldLabel><SheetSelect className="mt-1.5" value={value.companySize ?? ""} onValueChange={(companySize) => setValue({ ...value, companySize })} options={[{ value: "", label: "—" }, ...companySizes.map((size) => ({ value: size, label: size }))]} /></Field>
257
+ <Field><FieldLabel className={sheetFieldLabelClassName}>{dictionary.budgetRange}</FieldLabel><SheetSelect className="mt-1.5" value={value.budgetRange ?? ""} onValueChange={(budgetRange) => setValue({ ...value, budgetRange })} options={[{ value: "", label: "—" }, ...budgetRanges.map((range) => ({ value: range, label: range }))]} /></Field>
258
+ </FieldGroup>
259
+ </SheetSection>
260
+ <SheetSection title={dictionary.members} editing bodyClassName="space-y-3 px-4 py-3">
261
+ <div className="grid gap-2 sm:grid-cols-[1fr_auto_auto]">
262
+ <Input type="email" value={inviteDraft.email} onChange={(event) => setInviteDraft({ ...inviteDraft, email: event.target.value })} placeholder={dictionary.emailPlaceholder} className="h-8" />
263
+ <SheetSelect
264
+ aria-label={dictionary.members}
265
+ className="h-8"
266
+ value={inviteDraft.role}
267
+ onValueChange={(role) => setInviteDraft({ ...inviteDraft, role: role === "admin" ? "admin" : "member" })}
268
+ options={[
269
+ { value: "member", label: dictionary.member },
270
+ { value: "admin", label: dictionary.admin },
271
+ ]}
272
+ />
273
+ <Button type="button" variant="outline" size="sm" className="h-8" onClick={addInvitation}><Plus className="mr-1 size-3" />{dictionary.add}</Button>
274
+ </div>
275
+ {invitations.length === 0 ? <p className="text-meta text-foreground/55">{dictionary.optionalInvites}</p> : <div className="space-y-1.5">{invitations.map((invitation) => <div key={invitation.email} className="flex items-center justify-between rounded-lg border border-black/8 bg-background/60 px-2.5 py-1.5 text-meta dark:border-white/12"><span className="truncate">{invitation.email}</span><div className="flex items-center gap-2"><span className="rounded-full border px-2 py-0.5 text-micro font-semibold">{invitation.role === "admin" ? dictionary.admin : dictionary.member}</span><button type="button" className="text-destructive" onClick={() => setInvitations((current) => current.filter((item) => item.email !== invitation.email))}>{dictionary.remove}</button></div></div>)}</div>}
276
+ <p className="text-meta text-foreground/55">{dictionary.inviteHint}</p>
277
+ </SheetSection>
278
+ </AppSheetBody>
279
+ <AppSheetFooter className="flex-row"><Button type="submit" className="flex-1" disabled={saving || !value.name.trim()}><Save className="mr-2 size-4" />{saving ? dictionary.creating : dictionary.create}</Button><Button type="button" variant="outline" className="flex-1" disabled={saving} onClick={requestClose}>{dictionary.cancel}</Button></AppSheetFooter>
280
+ </form>
281
+ </SheetContent>
282
+ </Sheet>
283
+ <AlertDialog open={discardOpen} onOpenChange={setDiscardOpen}>
284
+ <AlertDialogContent>
285
+ <AlertDialogHeader>
286
+ <AlertDialogTitle>{dictionary.discardTitle}</AlertDialogTitle>
287
+ <AlertDialogDescription>{dictionary.discardDescription}</AlertDialogDescription>
288
+ </AlertDialogHeader>
289
+ <AlertDialogFooter>
290
+ <AlertDialogCancel>{dictionary.continueEditing}</AlertDialogCancel>
291
+ <AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={() => { setDiscardOpen(false); onOpenChange(false); }}>
292
+ {dictionary.discard}
293
+ </AlertDialogAction>
294
+ </AlertDialogFooter>
295
+ </AlertDialogContent>
296
+ </AlertDialog>
297
+ </>
298
+ );
299
+ }