update account creation fileds
This commit is contained in:
@@ -15,14 +15,23 @@ interface AdminUser {
|
||||
}
|
||||
|
||||
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
||||
const EMPTY_FORM = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'SUPPORT',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
@@ -44,25 +53,61 @@ export default function AdminUsersPage() {
|
||||
|
||||
useEffect(() => { fetchAdmins() }, [])
|
||||
|
||||
async function createAdmin(e: React.FormEvent) {
|
||||
function openCreateModal() {
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function openEditModal(admin: AdminUser) {
|
||||
setEditingAdminId(admin.id)
|
||||
setForm({
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
email: admin.email,
|
||||
password: '',
|
||||
role: admin.role,
|
||||
isActive: admin.isActive,
|
||||
})
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setShowModal(false)
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function saveAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
|
||||
method: 'POST',
|
||||
const isEditing = Boolean(editingAdminId)
|
||||
const payload = {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
isActive: form.isActive,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
}
|
||||
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
||||
method: isEditing ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
|
||||
setShowModal(false)
|
||||
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
if (!res.ok) throw new Error(json?.message ?? `Failed to ${isEditing ? 'update' : 'create'} admin`)
|
||||
closeModal()
|
||||
await fetchAdmins()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +127,7 @@ export default function AdminUsersPage() {
|
||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
+ New admin
|
||||
@@ -102,13 +147,14 @@ export default function AdminUsersPage() {
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Permissions</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Joined</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
) : admins.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
|
||||
@@ -129,6 +175,15 @@ export default function AdminUsersPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(a)}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -140,10 +195,10 @@ export default function AdminUsersPage() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">New admin user</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
<h2 className="text-lg font-semibold">{editingAdminId ? 'Edit admin user' : 'New admin user'}</h2>
|
||||
<button onClick={closeModal} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createAdmin} className="space-y-4">
|
||||
<form onSubmit={saveAdmin} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||
@@ -175,11 +230,13 @@ export default function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Password</label>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||
Password {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
required={!editingAdminId}
|
||||
minLength={editingAdminId ? undefined : 8}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
@@ -195,10 +252,21 @@ export default function AdminUsersPage() {
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.isActive ? 'active' : 'inactive'}
|
||||
onChange={(e) => setForm({ ...form, isActive: e.target.value === 'active' })}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={creating} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{creating ? 'Creating…' : 'Create admin'}
|
||||
<button type="button" onClick={closeModal} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{saving ? (editingAdminId ? 'Saving…' : 'Creating…') : (editingAdminId ? 'Save changes' : 'Create admin')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -322,6 +322,31 @@ export function updateAdminRole(id: string, role: string) {
|
||||
return prisma.adminUser.update({ where: { id }, data: { role: role as any } })
|
||||
}
|
||||
|
||||
export function updateAdmin(
|
||||
id: string,
|
||||
data: {
|
||||
email?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
passwordHash?: string
|
||||
isActive?: boolean
|
||||
},
|
||||
) {
|
||||
return prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.email !== undefined ? { email: data.email } : {}),
|
||||
...(data.firstName !== undefined ? { firstName: data.firstName } : {}),
|
||||
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
|
||||
...(data.role !== undefined ? { role: data.role as any } : {}),
|
||||
...(data.passwordHash !== undefined ? { passwordHash: data.passwordHash } : {}),
|
||||
...(data.isActive !== undefined ? { isActive: data.isActive } : {}),
|
||||
},
|
||||
include: { permissions: true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function replaceAdminPermissions(id: string, permissions: any[]) {
|
||||
await prisma.adminUser.findUniqueOrThrow({ where: { id } })
|
||||
await prisma.$transaction([
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
companiesQuerySchema, rentersQuerySchema, auditLogQuerySchema, billingQuerySchema, notificationsQuerySchema,
|
||||
invoicesQuerySchema, adminCompanyUpdateSchema, companyStatusSchema,
|
||||
createAdminSchema, adminRoleSchema, adminPermissionsSchema,
|
||||
updateAdminSchema,
|
||||
homepageUpdateSchema, idParamSchema, companyIdParamSchema, billingAccountIdParamSchema, invoiceIdParamSchema,
|
||||
pricingUpdateSchema, planFeatureCreateSchema, planFeatureUpdateSchema, planFeatureIdParamSchema,
|
||||
promotionCreateSchema, promotionUpdateSchema, promotionIdParamSchema,
|
||||
@@ -188,6 +189,13 @@ router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
|
||||
@@ -71,14 +71,23 @@ export const permissionSchema = z.object({
|
||||
})
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
email: z.string().email(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
email: z.string().email().trim().toLowerCase(),
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
})
|
||||
|
||||
export const updateAdminSchema = z.object({
|
||||
email: z.string().email().trim().toLowerCase().optional(),
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const adminRoleSchema = z.object({
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
})
|
||||
|
||||
@@ -216,6 +216,29 @@ export async function createAdmin(body: { email: string; firstName: string; last
|
||||
return presenter.presentAdminUser(admin)
|
||||
}
|
||||
|
||||
export async function updateAdmin(
|
||||
id: string,
|
||||
body: {
|
||||
email?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
role?: string
|
||||
password?: string
|
||||
isActive?: boolean
|
||||
},
|
||||
) {
|
||||
const admin = await repo.updateAdmin(id, {
|
||||
...(body.email !== undefined ? { email: body.email } : {}),
|
||||
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
|
||||
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
|
||||
...(body.role !== undefined ? { role: body.role } : {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
...(body.password ? { passwordHash: await bcrypt.hash(body.password, 12) } : {}),
|
||||
})
|
||||
|
||||
return presenter.presentAdminUser(admin)
|
||||
}
|
||||
|
||||
export function updateAdminRole(id: string, role: string) {
|
||||
return repo.updateAdminRole(id, role)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ type DbClient = Pick<typeof prisma, 'company' | 'brandSettings' | 'contractSetti
|
||||
|
||||
type CompanySignupInput = {
|
||||
companyName: string
|
||||
legalName: string
|
||||
slug: string
|
||||
email: string
|
||||
ownerEmail: string
|
||||
companyEmail: string
|
||||
companyPhone: string
|
||||
streetAddress: string
|
||||
city: string
|
||||
@@ -13,8 +15,21 @@ type CompanySignupInput = {
|
||||
zipCode: string
|
||||
legalForm: string
|
||||
managerName: string
|
||||
iceNumber: string
|
||||
taxId: string
|
||||
operatingLicenseNumber: string
|
||||
operatingLicenseIssuedAt: string
|
||||
operatingLicenseIssuedBy: string
|
||||
fax?: string
|
||||
yearsActive: string
|
||||
representativeName?: string
|
||||
representativeTitle?: string
|
||||
responsibleName: string
|
||||
responsibleRole: string
|
||||
responsibleIdentityNumber: string
|
||||
responsibleQualification?: string
|
||||
responsiblePhone: string
|
||||
responsibleEmail: string
|
||||
currency: 'MAD'
|
||||
registrationNumber: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
@@ -44,17 +59,31 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
||||
data: {
|
||||
name: input.companyName,
|
||||
slug: input.slug,
|
||||
email: input.email,
|
||||
email: input.companyEmail,
|
||||
phone: input.companyPhone,
|
||||
address: {
|
||||
streetAddress: input.streetAddress,
|
||||
city: input.city,
|
||||
country: input.country,
|
||||
zipCode: input.zipCode,
|
||||
legalName: input.legalName,
|
||||
legalForm: input.legalForm,
|
||||
companyEmail: input.companyEmail,
|
||||
managerName: input.managerName,
|
||||
iceNumber: input.iceNumber,
|
||||
operatingLicenseNumber: input.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: input.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: input.operatingLicenseIssuedBy,
|
||||
fax: input.fax || null,
|
||||
yearsActive: input.yearsActive,
|
||||
representativeName: input.representativeName || null,
|
||||
representativeTitle: input.representativeTitle || null,
|
||||
responsibleName: input.responsibleName,
|
||||
responsibleRole: input.responsibleRole,
|
||||
responsibleIdentityNumber: input.responsibleIdentityNumber,
|
||||
responsibleQualification: input.responsibleQualification || null,
|
||||
responsiblePhone: input.responsiblePhone,
|
||||
responsibleEmail: input.responsibleEmail,
|
||||
},
|
||||
status: 'TRIALING',
|
||||
},
|
||||
@@ -65,7 +94,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
||||
companyId: company.id,
|
||||
displayName: input.companyName,
|
||||
subdomain: input.slug,
|
||||
publicEmail: input.email,
|
||||
publicEmail: input.companyEmail,
|
||||
publicPhone: input.companyPhone,
|
||||
publicAddress: input.streetAddress,
|
||||
publicCity: input.city,
|
||||
@@ -78,8 +107,9 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
||||
await db.contractSettings.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
legalName: input.companyName,
|
||||
legalName: input.legalName,
|
||||
registrationNumber: input.registrationNumber,
|
||||
taxId: input.taxId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -103,7 +133,7 @@ export async function createCompanySignup(db: DbClient, input: CompanySignupInpu
|
||||
clerkUserId: `local_owner_${company.id}`,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
email: input.email,
|
||||
email: input.ownerEmail,
|
||||
phone: input.companyPhone,
|
||||
passwordHash: input.passwordHash,
|
||||
role: 'OWNER',
|
||||
|
||||
@@ -6,15 +6,30 @@ export const companySignupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
legalName: z.string().min(2).max(160),
|
||||
legalForm: z.string().min(1).max(80),
|
||||
registrationNumber: z.string().min(1).max(120),
|
||||
iceNumber: z.string().min(1).max(120),
|
||||
taxId: z.string().min(1).max(120),
|
||||
operatingLicenseNumber: z.string().min(1).max(120),
|
||||
operatingLicenseIssuedAt: z.string().min(1).max(40),
|
||||
operatingLicenseIssuedBy: z.string().min(1).max(160),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
city: z.string().min(1).max(120),
|
||||
country: z.string().min(1).max(120),
|
||||
zipCode: z.string().min(1).max(40),
|
||||
companyPhone: z.string().min(1).max(80),
|
||||
companyEmail: z.string().email(),
|
||||
fax: z.string().max(80).optional(),
|
||||
yearsActive: z.string().min(1).max(80),
|
||||
representativeName: z.string().max(160).optional(),
|
||||
representativeTitle: z.string().max(120).optional(),
|
||||
responsibleName: z.string().min(1).max(160),
|
||||
responsibleRole: z.string().min(1).max(120),
|
||||
responsibleIdentityNumber: z.string().min(1).max(120),
|
||||
responsibleQualification: z.string().max(200).optional(),
|
||||
responsiblePhone: z.string().min(1).max(80),
|
||||
responsibleEmail: z.string().email(),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
|
||||
@@ -32,12 +32,12 @@ async function generateUniqueSlug(baseName: string) {
|
||||
}
|
||||
|
||||
export async function signup(body: CompanySignupInput) {
|
||||
if (await repo.findCompanyByEmail(body.email)) {
|
||||
throw new AppError('A company account with this email already exists', 409, 'email_taken')
|
||||
if (await repo.findCompanyByEmail(body.companyEmail)) {
|
||||
throw new AppError('A company account with this contact email already exists', 409, 'company_email_taken')
|
||||
}
|
||||
|
||||
if (await repo.findEmployeeByEmail(body.email)) {
|
||||
throw new AppError('An employee account with this email already exists', 409, 'email_taken')
|
||||
throw new AppError('An employee account with this owner email already exists', 409, 'owner_email_taken')
|
||||
}
|
||||
|
||||
const slug = await generateUniqueSlug(body.companyName)
|
||||
@@ -47,8 +47,10 @@ export async function signup(body: CompanySignupInput) {
|
||||
|
||||
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
|
||||
companyName: body.companyName,
|
||||
legalName: body.legalName,
|
||||
slug,
|
||||
email: body.email,
|
||||
ownerEmail: body.email,
|
||||
companyEmail: body.companyEmail,
|
||||
companyPhone: body.companyPhone,
|
||||
streetAddress: body.streetAddress,
|
||||
city: body.city,
|
||||
@@ -56,8 +58,21 @@ export async function signup(body: CompanySignupInput) {
|
||||
zipCode: body.zipCode,
|
||||
legalForm: body.legalForm,
|
||||
managerName: `${body.firstName} ${body.lastName}`.trim(),
|
||||
iceNumber: body.iceNumber,
|
||||
taxId: body.taxId,
|
||||
operatingLicenseNumber: body.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: body.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: body.operatingLicenseIssuedBy,
|
||||
fax: body.fax,
|
||||
yearsActive: body.yearsActive,
|
||||
representativeName: body.representativeName,
|
||||
representativeTitle: body.representativeTitle,
|
||||
responsibleName: body.responsibleName,
|
||||
responsibleRole: body.responsibleRole,
|
||||
responsibleIdentityNumber: body.responsibleIdentityNumber,
|
||||
responsibleQualification: body.responsibleQualification,
|
||||
responsiblePhone: body.responsiblePhone,
|
||||
responsibleEmail: body.responsibleEmail,
|
||||
currency: body.currency,
|
||||
registrationNumber: body.registrationNumber,
|
||||
plan: body.plan,
|
||||
|
||||
@@ -19,14 +19,29 @@ describe('Company signup API', () => {
|
||||
email: uniqueEmail('trial-owner'),
|
||||
password: 'Password123!',
|
||||
companyName: `Trial Cars ${Date.now()}`,
|
||||
legalName: `Trial Cars SARL ${Date.now()}`,
|
||||
legalForm: 'LLC',
|
||||
registrationNumber: `REG-${Date.now()}`,
|
||||
iceNumber: `ICE-${Date.now()}`,
|
||||
taxId: `IF-${Date.now()}`,
|
||||
operatingLicenseNumber: `AGR-${Date.now()}`,
|
||||
operatingLicenseIssuedAt: '2026-01-15',
|
||||
operatingLicenseIssuedBy: 'Wilaya de Casablanca',
|
||||
streetAddress: '123 Main Street',
|
||||
city: 'Casablanca',
|
||||
country: 'Morocco',
|
||||
zipCode: '20000',
|
||||
companyPhone: '+212600000000',
|
||||
companyEmail: uniqueEmail('trial-company'),
|
||||
yearsActive: '1',
|
||||
representativeName: 'Trial Representative',
|
||||
representativeTitle: 'Managing Director',
|
||||
responsibleName: 'Operations Lead',
|
||||
responsibleRole: 'Responsible Manager',
|
||||
responsibleIdentityNumber: `CIN-${Date.now()}`,
|
||||
responsibleQualification: '10 years fleet operations experience',
|
||||
responsiblePhone: '+212611111111',
|
||||
responsibleEmail: uniqueEmail('trial-responsible'),
|
||||
preferredLanguage: 'en',
|
||||
plan: 'STARTER',
|
||||
billingPeriod: 'MONTHLY',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
|
||||
import { BilingualField, BilingualInput, emptyBilingual } from '@/components/ui/BilingualInput'
|
||||
|
||||
type SignupForm = {
|
||||
firstName: BilingualField
|
||||
@@ -18,15 +18,30 @@ type SignupForm = {
|
||||
confirmPassword: string
|
||||
preferredLanguage: 'en' | 'fr' | 'ar' | ''
|
||||
companyName: BilingualField
|
||||
legalName: string
|
||||
legalForm: string
|
||||
registrationNumber: string
|
||||
iceNumber: string
|
||||
taxId: string
|
||||
operatingLicenseNumber: string
|
||||
operatingLicenseIssuedAt: string
|
||||
operatingLicenseIssuedBy: string
|
||||
streetAddress: BilingualField
|
||||
city: BilingualField
|
||||
country: BilingualField
|
||||
zipCode: string
|
||||
companyPhone: string
|
||||
companyEmail: string
|
||||
fax: string
|
||||
yearsActive: string
|
||||
representativeName: string
|
||||
representativeTitle: string
|
||||
responsibleName: string
|
||||
responsibleRole: string
|
||||
responsibleIdentityNumber: string
|
||||
responsibleQualification: string
|
||||
responsiblePhone: string
|
||||
responsibleEmail: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'
|
||||
@@ -74,15 +89,30 @@ export default function SignUpPage() {
|
||||
confirmPassword: '',
|
||||
preferredLanguage: '',
|
||||
companyName: emptyBilingual(),
|
||||
legalName: '',
|
||||
legalForm: '',
|
||||
registrationNumber: '',
|
||||
iceNumber: '',
|
||||
taxId: '',
|
||||
operatingLicenseNumber: '',
|
||||
operatingLicenseIssuedAt: '',
|
||||
operatingLicenseIssuedBy: '',
|
||||
streetAddress: emptyBilingual(),
|
||||
city: emptyBilingual(),
|
||||
country: emptyBilingual(),
|
||||
zipCode: '',
|
||||
companyPhone: '',
|
||||
companyEmail: '',
|
||||
fax: '',
|
||||
yearsActive: '',
|
||||
representativeName: '',
|
||||
representativeTitle: '',
|
||||
responsibleName: '',
|
||||
responsibleRole: '',
|
||||
responsibleIdentityNumber: '',
|
||||
responsibleQualification: '',
|
||||
responsiblePhone: '',
|
||||
responsibleEmail: '',
|
||||
plan: initialPlan,
|
||||
billingPeriod: initialBillingPeriod,
|
||||
currency: initialCurrency,
|
||||
@@ -107,19 +137,39 @@ export default function SignUpPage() {
|
||||
firstName: 'Manager/Owner first name',
|
||||
lastName: 'Manager/Owner last name',
|
||||
ownerEmail: 'Manager/Owner email',
|
||||
emailReuseHint: 'You can reuse the same email for the owner, company, and responsible person.',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordHint: 'Use at least 8 characters for your company owner account.',
|
||||
companyName: 'Company Name',
|
||||
companyName: 'Commercial name',
|
||||
legalName: 'Legal company name',
|
||||
legalForm: 'Legal form',
|
||||
registrationNumber: 'Commercial Registry (RC)',
|
||||
iceNumber: 'ICE number',
|
||||
taxId: 'Fiscal ID (IF)',
|
||||
operatingLicenseNumber: 'Operating license number',
|
||||
operatingLicenseIssuedAt: 'License issue date',
|
||||
operatingLicenseIssuedBy: 'Issuing authority',
|
||||
streetAddress: 'Street Address',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
zipCode: 'Zip code',
|
||||
companyPhone: 'Phone',
|
||||
companyEmail: 'Company contact email',
|
||||
fax: 'Fax (optional)',
|
||||
yearsActive: 'Years active',
|
||||
representativeName: 'Legal representative name',
|
||||
representativeTitle: 'Legal representative title',
|
||||
responsibleName: 'Responsible person name',
|
||||
responsibleRole: 'Responsible person role',
|
||||
responsibleIdentityNumber: 'Responsible person ID number',
|
||||
responsibleQualification: 'Qualification / diploma / experience',
|
||||
responsiblePhone: 'Responsible person phone',
|
||||
responsibleEmail: 'Responsible person email',
|
||||
identitySection: 'Legal identity',
|
||||
contactSection: 'Company contact',
|
||||
representativeSection: 'Legal representative',
|
||||
responsibleSection: 'Responsible person',
|
||||
continue: 'Continue',
|
||||
back: 'Back',
|
||||
working: 'Working…',
|
||||
@@ -131,7 +181,10 @@ export default function SignUpPage() {
|
||||
reviewWorkspace: 'Workspace',
|
||||
reviewPlan: 'Plan',
|
||||
reviewOwnerEmail: 'Owner email',
|
||||
reviewCompanyEmail: 'Company email',
|
||||
reviewPhone: 'Phone',
|
||||
reviewLegalName: 'Legal name',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'Choose a password with at least 8 characters.',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
missingRequired: 'Fill in all required fields.',
|
||||
@@ -186,19 +239,39 @@ export default function SignUpPage() {
|
||||
firstName: 'Prénom du gérant/propriétaire',
|
||||
lastName: 'Nom du gérant/propriétaire',
|
||||
ownerEmail: 'E-mail du gérant/propriétaire',
|
||||
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, l’entreprise et le responsable.',
|
||||
password: 'Mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
|
||||
companyName: 'Nom de l’entreprise',
|
||||
companyName: 'Nom commercial',
|
||||
legalName: 'Raison sociale',
|
||||
legalForm: 'Forme juridique',
|
||||
registrationNumber: 'Registre du commerce (RC)',
|
||||
iceNumber: 'Numéro ICE',
|
||||
taxId: 'Identifiant fiscal (IF)',
|
||||
operatingLicenseNumber: 'Numéro d’agrément',
|
||||
operatingLicenseIssuedAt: 'Date de délivrance',
|
||||
operatingLicenseIssuedBy: 'Autorité délivrante',
|
||||
streetAddress: 'Adresse',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
zipCode: 'Code postal',
|
||||
companyPhone: 'Téléphone',
|
||||
companyEmail: 'E-mail de contact de l’entreprise',
|
||||
fax: 'Fax (optionnel)',
|
||||
yearsActive: 'Années d’activité',
|
||||
representativeName: 'Nom et prénom du représentant',
|
||||
representativeTitle: 'Fonction du représentant',
|
||||
responsibleName: 'Nom et prénom du responsable',
|
||||
responsibleRole: 'Qualité du responsable',
|
||||
responsibleIdentityNumber: 'N° de pièce d’identité',
|
||||
responsibleQualification: 'Qualification / diplôme / expérience',
|
||||
responsiblePhone: 'Téléphone du responsable',
|
||||
responsibleEmail: 'E-mail du responsable',
|
||||
identitySection: 'Identité juridique',
|
||||
contactSection: 'Coordonnées de l’entreprise',
|
||||
representativeSection: 'Représentant légal',
|
||||
responsibleSection: 'Responsable désigné',
|
||||
continue: 'Continuer',
|
||||
back: 'Retour',
|
||||
working: 'Traitement…',
|
||||
@@ -210,7 +283,10 @@ export default function SignUpPage() {
|
||||
reviewWorkspace: 'Espace',
|
||||
reviewPlan: 'Formule',
|
||||
reviewOwnerEmail: 'E-mail propriétaire',
|
||||
reviewCompanyEmail: 'E-mail entreprise',
|
||||
reviewPhone: 'Téléphone',
|
||||
reviewLegalName: 'Raison sociale',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'Choisissez un mot de passe d’au moins 8 caractères.',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
missingRequired: 'Renseignez tous les champs obligatoires.',
|
||||
@@ -265,19 +341,39 @@ export default function SignUpPage() {
|
||||
firstName: 'الاسم الأول للمدير/المالك',
|
||||
lastName: 'اسم العائلة للمدير/المالك',
|
||||
ownerEmail: 'بريد المدير/المالك الإلكتروني',
|
||||
emailReuseHint: 'يمكنك استخدام نفس البريد الإلكتروني للمالك والشركة والمسؤول.',
|
||||
password: 'كلمة المرور',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
|
||||
companyName: 'اسم الشركة',
|
||||
companyName: 'الاسم التجاري',
|
||||
legalName: 'الاسم القانوني للشركة',
|
||||
legalForm: 'الشكل القانوني',
|
||||
registrationNumber: 'السجل التجاري (RC)',
|
||||
iceNumber: 'رقم ICE',
|
||||
taxId: 'المعرف الضريبي (IF)',
|
||||
operatingLicenseNumber: 'رقم الترخيص',
|
||||
operatingLicenseIssuedAt: 'تاريخ إصدار الترخيص',
|
||||
operatingLicenseIssuedBy: 'الجهة المانحة',
|
||||
streetAddress: 'عنوان الشارع',
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
zipCode: 'الرمز البريدي',
|
||||
companyPhone: 'الهاتف',
|
||||
companyEmail: 'بريد الشركة للتواصل',
|
||||
fax: 'الفاكس (اختياري)',
|
||||
yearsActive: 'سنوات النشاط',
|
||||
representativeName: 'اسم الممثل القانوني',
|
||||
representativeTitle: 'صفة الممثل القانوني',
|
||||
responsibleName: 'اسم المسؤول',
|
||||
responsibleRole: 'صفة المسؤول',
|
||||
responsibleIdentityNumber: 'رقم وثيقة الهوية',
|
||||
responsibleQualification: 'المؤهل / الشهادة / الخبرة',
|
||||
responsiblePhone: 'هاتف المسؤول',
|
||||
responsibleEmail: 'بريد المسؤول الإلكتروني',
|
||||
identitySection: 'الهوية القانونية',
|
||||
contactSection: 'بيانات الشركة',
|
||||
representativeSection: 'الممثل القانوني',
|
||||
responsibleSection: 'الشخص المسؤول',
|
||||
continue: 'متابعة',
|
||||
back: 'رجوع',
|
||||
working: 'جارٍ التنفيذ…',
|
||||
@@ -289,7 +385,10 @@ export default function SignUpPage() {
|
||||
reviewWorkspace: 'المساحة',
|
||||
reviewPlan: 'الخطة',
|
||||
reviewOwnerEmail: 'بريد المالك',
|
||||
reviewCompanyEmail: 'بريد الشركة',
|
||||
reviewPhone: 'الهاتف',
|
||||
reviewLegalName: 'الاسم القانوني',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
|
||||
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
missingRequired: 'أكمل جميع الحقول الإلزامية.',
|
||||
@@ -335,10 +434,25 @@ export default function SignUpPage() {
|
||||
setError(dict.missingLanguage)
|
||||
return
|
||||
}
|
||||
const ownerEmail = normalizeEmail(form.email)
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
companyEmail: current.companyEmail || ownerEmail,
|
||||
responsibleEmail: current.responsibleEmail || ownerEmail,
|
||||
}))
|
||||
setError(null)
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
function handleStep2Continue() {
|
||||
if (!hasRequiredPartnerFields(form)) {
|
||||
setError(dict.missingRequired)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
async function submitSignup() {
|
||||
if (form.password.length < 8) {
|
||||
setError(dict.invalidPassword)
|
||||
@@ -370,9 +484,15 @@ export default function SignUpPage() {
|
||||
password: form.password,
|
||||
preferredLanguage: form.preferredLanguage || 'en',
|
||||
companyName: form.companyName.fr || form.companyName.ar,
|
||||
legalName: form.legalName,
|
||||
companyNameAr: form.companyName.ar || undefined,
|
||||
legalForm: form.legalForm,
|
||||
registrationNumber: form.registrationNumber,
|
||||
iceNumber: form.iceNumber,
|
||||
taxId: form.taxId,
|
||||
operatingLicenseNumber: form.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: form.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: form.operatingLicenseIssuedBy,
|
||||
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
|
||||
streetAddressAr: form.streetAddress.ar || undefined,
|
||||
city: form.city.fr || form.city.ar,
|
||||
@@ -381,8 +501,17 @@ export default function SignUpPage() {
|
||||
countryAr: form.country.ar || undefined,
|
||||
zipCode: form.zipCode,
|
||||
companyPhone: form.companyPhone,
|
||||
companyEmail: form.companyEmail,
|
||||
fax: form.fax || undefined,
|
||||
yearsActive: form.yearsActive,
|
||||
representativeName: form.representativeName || undefined,
|
||||
representativeTitle: form.representativeTitle || undefined,
|
||||
responsibleName: form.responsibleName,
|
||||
responsibleRole: form.responsibleRole,
|
||||
responsibleIdentityNumber: form.responsibleIdentityNumber,
|
||||
responsibleQualification: form.responsibleQualification || undefined,
|
||||
responsiblePhone: form.responsiblePhone,
|
||||
responsibleEmail: form.responsibleEmail,
|
||||
plan: form.plan,
|
||||
billingPeriod: form.billingPeriod,
|
||||
currency: form.currency,
|
||||
@@ -483,6 +612,7 @@ export default function SignUpPage() {
|
||||
<BilingualInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
||||
<BilingualInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(current) => setForm((f) => ({ ...f, lastName: current }))} />
|
||||
<LabeledInput label={dict.ownerEmail} required type="email" maxLength={254} value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: normalizeEmail(value) }))} />
|
||||
<p className="text-xs text-slate-500">{dict.emailReuseHint}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
@@ -522,12 +652,31 @@ export default function SignUpPage() {
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
|
||||
<div className="space-y-4">
|
||||
<FormSubsection title={dict.identitySection} />
|
||||
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
||||
<LabeledInput label={dict.legalName} required maxLength={160} value={form.legalName} onChange={(value) => setForm((current) => ({ ...current, legalName: normalizeTrimmed(value) }))} />
|
||||
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
|
||||
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeTitleCase(value) }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.iceNumber} required maxLength={120} value={form.iceNumber} onChange={(value) => setForm((current) => ({ ...current, iceNumber: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.taxId} required maxLength={120} value={form.taxId} onChange={(value) => setForm((current) => ({ ...current, taxId: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.operatingLicenseNumber} required maxLength={120} value={form.operatingLicenseNumber} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseNumber: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.operatingLicenseIssuedAt} required type="date" value={form.operatingLicenseIssuedAt} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedAt: value }))} />
|
||||
<LabeledInput label={dict.operatingLicenseIssuedBy} required maxLength={160} value={form.operatingLicenseIssuedBy} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedBy: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
|
||||
<FormSubsection title={dict.contactSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label={dict.companyEmail} required type="email" maxLength={254} value={form.companyEmail} onChange={(value) => setForm((current) => ({ ...current, companyEmail: normalizeEmail(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
|
||||
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
|
||||
</div>
|
||||
<BilingualInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -535,9 +684,28 @@ export default function SignUpPage() {
|
||||
<BilingualInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
||||
</div>
|
||||
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
|
||||
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
|
||||
|
||||
<FormSubsection title={dict.representativeSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.representativeName} maxLength={160} value={form.representativeName} onChange={(value) => setForm((current) => ({ ...current, representativeName: normalizeTrimmed(value) }))} />
|
||||
<LabeledInput label={dict.representativeTitle} maxLength={120} value={form.representativeTitle} onChange={(value) => setForm((current) => ({ ...current, representativeTitle: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
|
||||
<FormSubsection title={dict.responsibleSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsibleName} required maxLength={160} value={form.responsibleName} onChange={(value) => setForm((current) => ({ ...current, responsibleName: normalizeTrimmed(value) }))} />
|
||||
<LabeledInput label={dict.responsibleRole} required maxLength={120} value={form.responsibleRole} onChange={(value) => setForm((current) => ({ ...current, responsibleRole: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsibleIdentityNumber} required maxLength={120} value={form.responsibleIdentityNumber} onChange={(value) => setForm((current) => ({ ...current, responsibleIdentityNumber: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.responsibleQualification} maxLength={200} value={form.responsibleQualification} onChange={(value) => setForm((current) => ({ ...current, responsibleQualification: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsiblePhone} required maxLength={80} value={form.responsiblePhone} onChange={(value) => setForm((current) => ({ ...current, responsiblePhone: value }))} />
|
||||
<LabeledInput label={dict.responsibleEmail} required type="email" maxLength={254} value={form.responsibleEmail} onChange={(value) => setForm((current) => ({ ...current, responsibleEmail: normalizeEmail(value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(1)} onNext={handleStep2Continue} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -570,9 +738,12 @@ export default function SignUpPage() {
|
||||
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? <span className="ml-2 text-slate-400">/ {form.companyName.ar}</span> : null}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewLegalName}:</span> {form.legalName || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · MAD</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewCompanyEmail}:</span> {form.companyEmail || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewRegistration}:</span> {form.registrationNumber || '—'} / {form.iceNumber || '—'} / {form.taxId || '—'}</p>
|
||||
</div>
|
||||
<NavActions
|
||||
onBack={() => setStep(3)}
|
||||
@@ -598,7 +769,25 @@ function hasRequiredPartnerFields(form: SignupForm) {
|
||||
bilingualFilled(form.streetAddress) &&
|
||||
bilingualFilled(form.city) &&
|
||||
bilingualFilled(form.country) &&
|
||||
[form.legalForm, form.registrationNumber, form.zipCode, form.companyPhone, form.yearsActive]
|
||||
[
|
||||
form.legalName,
|
||||
form.legalForm,
|
||||
form.registrationNumber,
|
||||
form.iceNumber,
|
||||
form.taxId,
|
||||
form.operatingLicenseNumber,
|
||||
form.operatingLicenseIssuedAt,
|
||||
form.operatingLicenseIssuedBy,
|
||||
form.zipCode,
|
||||
form.companyPhone,
|
||||
form.companyEmail,
|
||||
form.yearsActive,
|
||||
form.responsibleName,
|
||||
form.responsibleRole,
|
||||
form.responsibleIdentityNumber,
|
||||
form.responsiblePhone,
|
||||
form.responsibleEmail,
|
||||
]
|
||||
.every((v) => v.trim().length > 0)
|
||||
)
|
||||
}
|
||||
@@ -612,6 +801,10 @@ function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FormSubsection({ title }: { title: string }) {
|
||||
return <h3 className="pt-2 text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">{title}</h3>
|
||||
}
|
||||
|
||||
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery'], language: 'en' | 'fr' | 'ar') {
|
||||
if (!emailDelivery) return null
|
||||
if (emailDelivery.success) return null
|
||||
@@ -630,17 +823,14 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeTitleCase(value: string) {
|
||||
if (!value) return value
|
||||
const trimmedStart = value.replace(/^\s+/, '')
|
||||
if (!trimmedStart) return ''
|
||||
return trimmedStart.charAt(0).toUpperCase() + trimmedStart.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
return value.replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeTrimmed(value: string) {
|
||||
return value.replace(/^\s+/, '')
|
||||
}
|
||||
|
||||
function normalizeUpper(value: string) {
|
||||
return value.replace(/^\s+/, '').toUpperCase()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user