Переглянути джерело

Show the seat limit before inviting, and theme the toasts (#468)

* Show the seat limit before the invite form and check invite emails

A full plan marks the office choice and opens the upgrade dialog from it, instead of after the address is typed. The invite form checks the address with the server's rule and explains a bad one in the workshop's language.

* Give toasts the app's own look

Every toast gets the glass surface, icon tile and primary hairline, tinted by kind, instead of sonner's pastel rich colors. The update and signed-out notices use a themed card with their one action.

* Forget the seat answer between openings and announce the invite error

Clears the cached seat count when the dialog opens, so a freed seat is never locked by an old answer, and gives the email error role=alert tied to the field.

* Test the seat limit on the office choice, before the invite form
Bernt Christian Egeland 6 днів тому
батько
коміт
8de12e772c

+ 11 - 5
e2e/specs/cloud/plan-gates.spec.ts

@@ -137,18 +137,24 @@ test.describe('the free plan', () => {
   })
   })
 
 
   test('offers an upgrade when inviting a colleague', async ({ page }) => {
   test('offers an upgrade when inviting a colleague', async ({ page }) => {
-    // The free plan is one person, so the first invitation is the limit.
+    // The free plan is one person, so the first invitation is the limit. The
+    // add-person dialog says so on the office choice, before an address is
+    // typed: a workshop in PostHog filled in the whole form four times first.
     await page.goto('/settings/team')
     await page.goto('/settings/team')
     await settle(page)
     await settle(page)
+    const office = page.getByRole('button', { name: /Someone in the office/ })
     await expect(async () => {
     await expect(async () => {
       await page.getByRole('button', { name: 'Add', exact: true }).first().click()
       await page.getByRole('button', { name: 'Add', exact: true }).first().click()
-      await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+      await expect(office).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
     }).toPass({ timeout: 30_000 })
-    await page.getByText('Someone in the office').click()
-    await page.locator('#member-email').fill(`e2e-colleague-${stamp}@example.com`)
-    await page.getByRole('button', { name: 'Invite', exact: true }).click()
+    await expect(office, 'the full plan is named on the choice').toContainText(
+      'Your plan includes one team member'
+    )
+    await expect(office).toHaveAttribute('data-locked', 'true')
 
 
+    await office.click()
     await expectUpgradeOffered(page, /one team member/)
     await expectUpgradeOffered(page, /one team member/)
+    await expect(page.locator('#member-email'), 'no form to fill in for nothing').toHaveCount(0)
     expect(await teamInvitations(organizationId), 'no invitation went out').toBe(0)
     expect(await teamInvitations(organizationId), 'no invitation went out').toBe(0)
   })
   })
 
 

+ 2 - 1
messages/de/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} wurde aus der App abgemeldet",
     "revokeTechnicianDone": "{name} wurde aus der App abgemeldet",
     "noAccessTitle": "Noch kein Zugriff",
     "noAccessTitle": "Noch kein Zugriff",
     "noAccessBody": "Ihr Konto gehört zu {workshop}, hat aber noch keine Rolle, deshalb gibt es hier nichts zu öffnen. Bitten Sie einen Inhaber oder Administrator Ihrer Werkstatt um eine Rolle.",
     "noAccessBody": "Ihr Konto gehört zu {workshop}, hat aber noch keine Rolle, deshalb gibt es hier nichts zu öffnen. Bitten Sie einen Inhaber oder Administrator Ihrer Werkstatt um eine Rolle.",
-    "setupPageInstall": "Bei Google Play laden"
+    "setupPageInstall": "Bei Google Play laden",
+    "invalidInviteEmail": "Prüfen Sie die E-Mail-Adresse. Sie sollte so aussehen: name@beispiel.de."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Rechnungslayout",
     "title": "Rechnungslayout",

+ 2 - 1
messages/en/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} has been signed out of the app",
     "revokeTechnicianDone": "{name} has been signed out of the app",
     "noAccessTitle": "No access yet",
     "noAccessTitle": "No access yet",
     "noAccessBody": "Your account is part of {workshop}, but it has not been given a role, so there is nothing here you can open yet. Ask an owner or an admin at your workshop to give you one.",
     "noAccessBody": "Your account is part of {workshop}, but it has not been given a role, so there is nothing here you can open yet. Ask an owner or an admin at your workshop to give you one.",
-    "setupPageInstall": "Get it on Google Play"
+    "setupPageInstall": "Get it on Google Play",
+    "invalidInviteEmail": "Check the email address. It should look like name@example.com."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Invoice & Quotes",
     "title": "Invoice & Quotes",

+ 2 - 1
messages/es/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} ha salido de la aplicación",
     "revokeTechnicianDone": "{name} ha salido de la aplicación",
     "noAccessTitle": "Sin acceso todavía",
     "noAccessTitle": "Sin acceso todavía",
     "noAccessBody": "Su cuenta pertenece a {workshop}, pero no tiene un rol asignado, así que aquí no hay nada que pueda abrir. Pida un rol a un propietario o administrador de su taller.",
     "noAccessBody": "Su cuenta pertenece a {workshop}, pero no tiene un rol asignado, así que aquí no hay nada que pueda abrir. Pida un rol a un propietario o administrador de su taller.",
-    "setupPageInstall": "Consíguelo en Google Play"
+    "setupPageInstall": "Consíguelo en Google Play",
+    "invalidInviteEmail": "Revisa la dirección de correo. Debe tener la forma nombre@ejemplo.com."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Diseño de factura",
     "title": "Diseño de factura",

+ 2 - 1
messages/fr/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} a été déconnecté de l’application",
     "revokeTechnicianDone": "{name} a été déconnecté de l’application",
     "noAccessTitle": "Pas encore d’accès",
     "noAccessTitle": "Pas encore d’accès",
     "noAccessBody": "Votre compte fait partie de {workshop}, mais aucun rôle ne lui a été attribué, donc il n’y a rien à ouvrir ici. Demandez un rôle à un propriétaire ou un administrateur de votre atelier.",
     "noAccessBody": "Votre compte fait partie de {workshop}, mais aucun rôle ne lui a été attribué, donc il n’y a rien à ouvrir ici. Demandez un rôle à un propriétaire ou un administrateur de votre atelier.",
-    "setupPageInstall": "Télécharger sur Google Play"
+    "setupPageInstall": "Télécharger sur Google Play",
+    "invalidInviteEmail": "Vérifiez l'adresse e-mail. Elle doit ressembler à nom@exemple.fr."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Mise en page de facture",
     "title": "Mise en page de facture",

+ 2 - 1
messages/it/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} è stato disconnesso dall’app",
     "revokeTechnicianDone": "{name} è stato disconnesso dall’app",
     "noAccessTitle": "Ancora nessun accesso",
     "noAccessTitle": "Ancora nessun accesso",
     "noAccessBody": "Il tuo account fa parte di {workshop}, ma non ha ancora un ruolo, quindi qui non c’è nulla da aprire. Chiedi un ruolo a un titolare o a un amministratore della tua officina.",
     "noAccessBody": "Il tuo account fa parte di {workshop}, ma non ha ancora un ruolo, quindi qui non c’è nulla da aprire. Chiedi un ruolo a un titolare o a un amministratore della tua officina.",
-    "setupPageInstall": "Scaricala su Google Play"
+    "setupPageInstall": "Scaricala su Google Play",
+    "invalidInviteEmail": "Controlla l'indirizzo email. Deve avere la forma nome@esempio.it."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Layout fattura",
     "title": "Layout fattura",

+ 2 - 1
messages/lt/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} atjungtas nuo programėlės",
     "revokeTechnicianDone": "{name} atjungtas nuo programėlės",
     "noAccessTitle": "Prieigos kol kas nėra",
     "noAccessTitle": "Prieigos kol kas nėra",
     "noAccessBody": "Jūsų paskyra priklauso {workshop}, bet jai dar nesuteikta rolė, todėl čia nėra ką atidaryti. Paprašykite dirbtuvių savininko arba administratoriaus suteikti jums rolę.",
     "noAccessBody": "Jūsų paskyra priklauso {workshop}, bet jai dar nesuteikta rolė, todėl čia nėra ką atidaryti. Paprašykite dirbtuvių savininko arba administratoriaus suteikti jums rolę.",
-    "setupPageInstall": "Atsisiųsti iš „Google Play“"
+    "setupPageInstall": "Atsisiųsti iš „Google Play“",
+    "invalidInviteEmail": "Patikrinkite el. pašto adresą. Jis turėtų atrodyti taip: vardas@pavyzdys.lt."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Sąskaitos ir pasiūlymai",
     "title": "Sąskaitos ir pasiūlymai",

+ 2 - 1
messages/nb/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} er logget ut av appen",
     "revokeTechnicianDone": "{name} er logget ut av appen",
     "noAccessTitle": "Ingen tilgang ennå",
     "noAccessTitle": "Ingen tilgang ennå",
     "noAccessBody": "Kontoen din tilhører {workshop}, men den har ikke fått en rolle ennå, så det er ingenting her du kan åpne. Be en eier eller administrator på verkstedet om å gi deg en.",
     "noAccessBody": "Kontoen din tilhører {workshop}, men den har ikke fått en rolle ennå, så det er ingenting her du kan åpne. Be en eier eller administrator på verkstedet om å gi deg en.",
-    "setupPageInstall": "Last ned fra Google Play"
+    "setupPageInstall": "Last ned fra Google Play",
+    "invalidInviteEmail": "Sjekk e-postadressen. Den skal se ut som navn@eksempel.no."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Faktura og tilbud",
     "title": "Faktura og tilbud",

+ 2 - 1
messages/nl/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} is uitgelogd uit de app",
     "revokeTechnicianDone": "{name} is uitgelogd uit de app",
     "noAccessTitle": "Nog geen toegang",
     "noAccessTitle": "Nog geen toegang",
     "noAccessBody": "Uw account hoort bij {workshop}, maar heeft nog geen rol, dus er is hier niets te openen. Vraag een eigenaar of beheerder van uw werkplaats om een rol.",
     "noAccessBody": "Uw account hoort bij {workshop}, maar heeft nog geen rol, dus er is hier niets te openen. Vraag een eigenaar of beheerder van uw werkplaats om een rol.",
-    "setupPageInstall": "Downloaden via Google Play"
+    "setupPageInstall": "Downloaden via Google Play",
+    "invalidInviteEmail": "Controleer het e-mailadres. Het moet eruitzien als naam@voorbeeld.nl."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Factuurindeling",
     "title": "Factuurindeling",

+ 2 - 1
messages/pl/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} został wylogowany z aplikacji",
     "revokeTechnicianDone": "{name} został wylogowany z aplikacji",
     "noAccessTitle": "Brak dostępu",
     "noAccessTitle": "Brak dostępu",
     "noAccessBody": "Twoje konto należy do {workshop}, ale nie ma jeszcze przypisanej roli, więc nie ma tu nic, co możesz otworzyć. Poproś właściciela lub administratora warsztatu o rolę.",
     "noAccessBody": "Twoje konto należy do {workshop}, ale nie ma jeszcze przypisanej roli, więc nie ma tu nic, co możesz otworzyć. Poproś właściciela lub administratora warsztatu o rolę.",
-    "setupPageInstall": "Pobierz z Google Play"
+    "setupPageInstall": "Pobierz z Google Play",
+    "invalidInviteEmail": "Sprawdź adres e-mail. Powinien wyglądać tak: imie@przyklad.pl."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Układ faktury",
     "title": "Układ faktury",

+ 2 - 1
messages/pt-BR/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} foi desconectado do aplicativo",
     "revokeTechnicianDone": "{name} foi desconectado do aplicativo",
     "noAccessTitle": "Ainda sem acesso",
     "noAccessTitle": "Ainda sem acesso",
     "noAccessBody": "Sua conta faz parte da {workshop}, mas ainda não recebeu uma função, então não há nada aqui que você possa abrir. Peça uma função ao proprietário ou a um administrador da sua oficina.",
     "noAccessBody": "Sua conta faz parte da {workshop}, mas ainda não recebeu uma função, então não há nada aqui que você possa abrir. Peça uma função ao proprietário ou a um administrador da sua oficina.",
-    "setupPageInstall": "Baixar no Google Play"
+    "setupPageInstall": "Baixar no Google Play",
+    "invalidInviteEmail": "Confira o endereço de e-mail. Ele deve ter o formato nome@exemplo.com."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Faturas e orçamentos",
     "title": "Faturas e orçamentos",

+ 2 - 1
messages/pt-PT/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "Foi terminada a sessão de {name} na aplicação",
     "revokeTechnicianDone": "Foi terminada a sessão de {name} na aplicação",
     "noAccessTitle": "Ainda sem acesso",
     "noAccessTitle": "Ainda sem acesso",
     "noAccessBody": "A sua conta faz parte de {workshop}, mas ainda não lhe foi atribuída uma função, por isso ainda não há nada aqui que possa abrir. Peça ao proprietário ou a um administrador da sua oficina que lhe atribua uma.",
     "noAccessBody": "A sua conta faz parte de {workshop}, mas ainda não lhe foi atribuída uma função, por isso ainda não há nada aqui que possa abrir. Peça ao proprietário ou a um administrador da sua oficina que lhe atribua uma.",
-    "setupPageInstall": "Disponível no Google Play"
+    "setupPageInstall": "Disponível no Google Play",
+    "invalidInviteEmail": "Verifique o endereço de email. Deve ter o formato nome@exemplo.pt."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Faturas e orçamentos",
     "title": "Faturas e orçamentos",

+ 2 - 1
messages/ru/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} вышел из приложения",
     "revokeTechnicianDone": "{name} вышел из приложения",
     "noAccessTitle": "Доступа пока нет",
     "noAccessTitle": "Доступа пока нет",
     "noAccessBody": "Ваша учётная запись относится к {workshop}, но ей ещё не назначена роль, поэтому открыть здесь нечего. Попросите владельца или администратора мастерской назначить вам роль.",
     "noAccessBody": "Ваша учётная запись относится к {workshop}, но ей ещё не назначена роль, поэтому открыть здесь нечего. Попросите владельца или администратора мастерской назначить вам роль.",
-    "setupPageInstall": "Загрузить в Google Play"
+    "setupPageInstall": "Загрузить в Google Play",
+    "invalidInviteEmail": "Проверьте адрес электронной почты. Он должен выглядеть так: name@example.com."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Счета и предложения",
     "title": "Счета и предложения",

+ 2 - 1
messages/tr/settings.json

@@ -562,7 +562,8 @@
     "revokeTechnicianDone": "{name} uygulamadan çıkarıldı",
     "revokeTechnicianDone": "{name} uygulamadan çıkarıldı",
     "noAccessTitle": "Henüz erişim yok",
     "noAccessTitle": "Henüz erişim yok",
     "noAccessBody": "Hesabınız {workshop} atölyesine ait, ancak henüz bir rol verilmemiş; bu yüzden burada açabileceğiniz bir şey yok. Atölyenizdeki bir sahip ya da yöneticiden rol isteyin.",
     "noAccessBody": "Hesabınız {workshop} atölyesine ait, ancak henüz bir rol verilmemiş; bu yüzden burada açabileceğiniz bir şey yok. Atölyenizdeki bir sahip ya da yöneticiden rol isteyin.",
-    "setupPageInstall": "Google Play’den indirin"
+    "setupPageInstall": "Google Play’den indirin",
+    "invalidInviteEmail": "E-posta adresini kontrol edin. ad@ornek.com gibi görünmelidir."
   },
   },
   "invoice": {
   "invoice": {
     "title": "Fatura Düzeni",
     "title": "Fatura Düzeni",

+ 62 - 0
src/__tests__/components/notice-toast.test.tsx

@@ -0,0 +1,62 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { RefreshCw } from 'lucide-react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const custom = vi.fn()
+const dismiss = vi.fn()
+vi.mock('sonner', () => ({
+  toast: {
+    custom: (...args: unknown[]) => custom(...args),
+    dismiss: (...args: unknown[]) => dismiss(...args),
+  },
+}))
+
+import { NoticeToast, showNotice } from '@/components/notice-toast'
+
+const notice = {
+  icon: RefreshCw,
+  title: 'A new version is available',
+  description: 'Reload the page to get the latest updates.',
+  actionLabel: 'Reload',
+  dismissLabel: 'Dismiss',
+}
+
+describe('NoticeToast', () => {
+  beforeEach(() => {
+    custom.mockReset()
+    dismiss.mockReset()
+  })
+
+  it('says what happened and offers the one thing to do about it', () => {
+    const onAction = vi.fn()
+    render(<NoticeToast toastId="t1" {...notice} onAction={onAction} />)
+
+    expect(screen.getByRole('status')).toHaveTextContent('A new version is available')
+    expect(screen.getByText('Reload the page to get the latest updates.')).toBeInTheDocument()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Reload' }))
+    expect(onAction).toHaveBeenCalledTimes(1)
+  })
+
+  it('closes itself and nothing else', () => {
+    const onAction = vi.fn()
+    render(<NoticeToast toastId="t1" {...notice} onAction={onAction} />)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
+    expect(dismiss).toHaveBeenCalledWith('t1')
+    expect(onAction).not.toHaveBeenCalled()
+  })
+
+  it('stays up until it is acted on, one per kind of notice', () => {
+    showNotice({ id: 'app-update', ...notice, onAction: vi.fn() })
+
+    expect(custom).toHaveBeenCalledWith(expect.any(Function), {
+      id: 'app-update',
+      duration: Number.POSITIVE_INFINITY,
+      unstyled: true,
+    })
+    const renderCard = custom.mock.calls[0][0] as (id: string) => React.ReactElement
+    render(renderCard('app-update'))
+    expect(screen.getByTestId('notice-toast')).toHaveTextContent('Reload')
+  })
+})

+ 6 - 4
src/__tests__/components/online-tracker.test.tsx

@@ -2,7 +2,9 @@ import { act, render } from '@testing-library/react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 
 const toast = vi.fn()
 const toast = vi.fn()
-vi.mock('sonner', () => ({ toast: (...args: unknown[]) => toast(...args) }))
+vi.mock('@/components/notice-toast', () => ({
+  showNotice: (notice: unknown) => toast(notice),
+}))
 
 
 import { OnlineTracker } from '@/components/online-tracker'
 import { OnlineTracker } from '@/components/online-tracker'
 
 
@@ -69,9 +71,9 @@ describe('OnlineTracker', () => {
 
 
     expect(assign).not.toHaveBeenCalled()
     expect(assign).not.toHaveBeenCalled()
     expect(toast).toHaveBeenCalledTimes(1)
     expect(toast).toHaveBeenCalledTimes(1)
-    const [title, options] = toast.mock.calls[0]
-    expect(title).toBe('You have been signed out')
-    options.action.onClick()
+    const [notice] = toast.mock.calls[0]
+    expect(notice).toMatchObject({ title: 'You have been signed out', actionLabel: 'Sign in' })
+    notice.onAction()
     expect(assign).toHaveBeenCalledWith('/auth/sign-in?redirect=%2Fwork-orders%3Fstatus%3Dpending')
     expect(assign).toHaveBeenCalledWith('/auth/sign-in?redirect=%2Fwork-orders%3Fstatus%3Dpending')
   })
   })
 
 

+ 12 - 6
src/__tests__/components/update-watcher.test.tsx

@@ -2,7 +2,9 @@ import { act, render } from '@testing-library/react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 
 const toast = vi.fn()
 const toast = vi.fn()
-vi.mock('sonner', () => ({ toast: (...args: unknown[]) => toast(...args) }))
+vi.mock('@/components/notice-toast', () => ({
+  showNotice: (notice: unknown) => toast(notice),
+}))
 
 
 import {
 import {
   ACTION_NOT_FOUND_HEADER,
   ACTION_NOT_FOUND_HEADER,
@@ -90,10 +92,14 @@ describe('UpdateWatcher', () => {
     await flush()
     await flush()
 
 
     expect(toast).toHaveBeenCalledTimes(1)
     expect(toast).toHaveBeenCalledTimes(1)
-    const [title, options] = toast.mock.calls[0]
-    expect(title).toBe('A new version is available')
-    expect(options.action.label).toBe('Reload')
-    expect(options.duration).toBe(Number.POSITIVE_INFINITY)
+    const [notice] = toast.mock.calls[0]
+    expect(notice).toMatchObject({
+      id: 'app-update',
+      title: 'A new version is available',
+      description: 'Reload the page to get the latest updates.',
+      actionLabel: 'Reload',
+      dismissLabel: 'Dismiss',
+    })
   })
   })
 
 
   it('says nothing while the build is the same', async () => {
   it('says nothing while the build is the same', async () => {
@@ -134,7 +140,7 @@ describe('UpdateWatcher', () => {
     await flush()
     await flush()
 
 
     expect(toast).toHaveBeenCalledTimes(1)
     expect(toast).toHaveBeenCalledTimes(1)
-    expect(toast.mock.calls[0][0]).toBe('A new version is available')
+    expect(toast.mock.calls[0][0]).toMatchObject({ title: 'A new version is available' })
   })
   })
 
 
   it('hands every response back untouched', async () => {
   it('hands every response back untouched', async () => {

+ 172 - 0
src/__tests__/features/team/add-person-dialog.test.tsx

@@ -0,0 +1,172 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+
+const getMemberSeats = vi.fn()
+vi.mock('@/features/team/Actions/getMemberSeats', () => ({
+  getMemberSeats: () => getMemberSeats(),
+}))
+const inviteMember = vi.fn()
+vi.mock('@/features/team/Actions/teamActions', () => ({
+  inviteMember: (input: unknown) => inviteMember(input),
+}))
+const handleGated = vi.fn((_result: unknown) => true)
+vi.mock('@/components/upgrade-gate', () => ({
+  handleGated: (result: unknown) => handleGated(result),
+}))
+vi.mock('@/features/team/Actions/createAppSetupCode', () => ({
+  createAppSetupCode: vi.fn(),
+  revokeAppSetupCode: vi.fn(),
+}))
+vi.mock('@/features/team/Actions/createTechnicianAccount', () => ({
+  createTechnicianAccount: vi.fn(),
+  getWorkshopDialCode: vi.fn(),
+}))
+vi.mock('@/features/workboard/Actions/technicianActions', () => ({ createTechnician: vi.fn() }))
+vi.mock('@/features/team/Actions/getRoles', () => ({ getRoles: vi.fn() }))
+vi.mock('@/features/team/Actions/createDefaultRoles', () => ({ createDefaultRoles: vi.fn() }))
+vi.mock('@/features/team/hooks/useTechnicianConnected', () => ({
+  useTechnicianConnected: vi.fn(),
+}))
+
+import { AddPersonDialog } from '@/features/team/Components/AddPersonDialog'
+
+const OFFICE = 'Someone in the office'
+
+function dialog(open = true) {
+  return (
+    <AddPersonDialog
+      open={open}
+      onOpenChange={vi.fn()}
+      dialCode="+47"
+      roles={[{ id: 'role-desk', name: 'Desk' }]}
+      onChanged={vi.fn()}
+    />
+  )
+}
+
+function renderDialog() {
+  return render(dialog())
+}
+
+function officeChoice() {
+  return screen.getByRole('button', { name: new RegExp(OFFICE) })
+}
+
+describe('AddPersonDialog, office seats', () => {
+  beforeEach(() => {
+    getMemberSeats.mockReset()
+    inviteMember.mockReset()
+    handleGated.mockClear()
+  })
+
+  it('says the plan is full before any address is typed, and offers the upgrade', async () => {
+    getMemberSeats.mockResolvedValue({ success: true, data: { used: 1, limit: 1, full: true } })
+    renderDialog()
+
+    await waitFor(() => expect(officeChoice()).toHaveAttribute('data-locked', 'true'))
+    expect(officeChoice()).toHaveTextContent(
+      'Your plan includes one team member. Upgrade to invite more people.'
+    )
+
+    fireEvent.click(officeChoice())
+    expect(handleGated).toHaveBeenCalledWith({ gated: { feature: 'maxUsers', limit: 1 } })
+    expect(screen.queryByLabelText('Invite by Email')).toBeNull()
+  })
+
+  it('opens the invite form while a seat is free', async () => {
+    getMemberSeats.mockResolvedValue({ success: true, data: { used: 1, limit: 5, full: false } })
+    renderDialog()
+    await waitFor(() => expect(getMemberSeats).toHaveBeenCalled())
+
+    fireEvent.click(officeChoice())
+    expect(handleGated).not.toHaveBeenCalled()
+    expect(screen.getByLabelText('Invite by Email')).toBeInTheDocument()
+  })
+
+  it('still opens the form when the seat answer has not arrived', () => {
+    // An answer that never arrives
+    getMemberSeats.mockReturnValue(new Promise(vi.fn()))
+    renderDialog()
+
+    fireEvent.click(officeChoice())
+    expect(screen.getByLabelText('Invite by Email')).toBeInTheDocument()
+  })
+
+  it('forgets a full plan between openings when the new answer fails', async () => {
+    getMemberSeats.mockResolvedValueOnce({
+      success: true,
+      data: { used: 1, limit: 1, full: true },
+    })
+    const { rerender } = renderDialog()
+    await waitFor(() => expect(officeChoice()).toHaveAttribute('data-locked', 'true'))
+
+    // A member was removed meanwhile; this time the seat check itself fails
+    getMemberSeats.mockResolvedValueOnce({ success: false, error: 'Network error' })
+    rerender(dialog(false))
+    rerender(dialog(true))
+    await waitFor(() => expect(getMemberSeats).toHaveBeenCalledTimes(2))
+
+    expect(officeChoice()).not.toHaveAttribute('data-locked')
+    fireEvent.click(officeChoice())
+    expect(handleGated).not.toHaveBeenCalled()
+    expect(screen.getByLabelText('Invite by Email')).toBeInTheDocument()
+  })
+})
+
+describe('AddPersonDialog, invite address', () => {
+  beforeEach(() => {
+    getMemberSeats.mockReset()
+    getMemberSeats.mockResolvedValue({ success: true, data: { used: 1, limit: 5, full: false } })
+    inviteMember.mockReset()
+    inviteMember.mockResolvedValue({ success: true, data: { invited: true } })
+  })
+
+  async function openForm() {
+    renderDialog()
+    await waitFor(() => expect(getMemberSeats).toHaveBeenCalled())
+    fireEvent.click(officeChoice())
+    return screen.getByLabelText('Invite by Email')
+  }
+
+  it('refuses an address the server would refuse, in words, without asking it', async () => {
+    const field = await openForm()
+    // The browser's type="email" lets this through; the server does not
+    fireEvent.change(field, { target: { value: 'kari@gmail' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+
+    expect(
+      await screen.findByText('Check the email address. It should look like name@example.com.')
+    ).toBeInTheDocument()
+    expect(field).toHaveAttribute('aria-invalid', 'true')
+    // Announced, and read out with the field, since the browser says nothing
+    const message = screen.getByRole('alert')
+    expect(message).toHaveTextContent('Check the email address.')
+    expect(field).toHaveAttribute('aria-describedby', message.id)
+    expect(inviteMember).not.toHaveBeenCalled()
+  })
+
+  it('clears the message once the address is being corrected', async () => {
+    const field = await openForm()
+    fireEvent.change(field, { target: { value: 'kari@gmail' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+    await screen.findByText(/Check the email address/)
+
+    fireEvent.change(field, { target: { value: 'kari@gmail.com' } })
+    expect(screen.queryByText(/Check the email address/)).toBeNull()
+  })
+
+  it('sends a good address on to the invite action', async () => {
+    const field = await openForm()
+    fireEvent.change(field, { target: { value: ' kari@gmail.com ' } })
+    fireEvent.click(screen.getByRole('button', { name: 'Invite' }))
+
+    await waitFor(() =>
+      expect(inviteMember).toHaveBeenCalledWith(
+        expect.objectContaining({ email: 'kari@gmail.com' })
+      )
+    )
+  })
+})

+ 44 - 0
src/__tests__/features/team/member-seats.test.ts

@@ -0,0 +1,44 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({
+  db: { organizationMember: { count: vi.fn() } },
+}))
+vi.mock('@/lib/features', () => ({ getFeatures: vi.fn() }))
+
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+import { memberSeats, seatsFrom } from '@/features/team/Lib/memberSeats'
+
+describe('seatsFrom', () => {
+  it('is full once every seat is taken', () => {
+    expect(seatsFrom(1, 1)).toEqual({ used: 1, limit: 1, full: true })
+  })
+
+  it('stays full past the limit, as after a downgrade', () => {
+    expect(seatsFrom(3, 1).full).toBe(true)
+  })
+
+  it('has room while a seat is free', () => {
+    expect(seatsFrom(4, 5)).toEqual({ used: 4, limit: 5, full: false })
+  })
+})
+
+describe('memberSeats', () => {
+  beforeEach(() => {
+    vi.mocked(db.organizationMember.count).mockReset()
+    vi.mocked(getFeatures).mockReset()
+  })
+
+  it("counts the workshop's members against its plan", async () => {
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1)
+    vi.mocked(getFeatures).mockResolvedValue({ maxUsers: 1 } as Awaited<
+      ReturnType<typeof getFeatures>
+    >)
+
+    await expect(memberSeats('org-1')).resolves.toEqual({ used: 1, limit: 1, full: true })
+    expect(db.organizationMember.count).toHaveBeenCalledWith({
+      where: { organizationId: 'org-1' },
+    })
+    expect(getFeatures).toHaveBeenCalledWith('org-1')
+  })
+})

+ 86 - 0
src/app/globals.css

@@ -698,3 +698,89 @@ html body[data-scroll-locked] {
   -webkit-text-security: disc;
   -webkit-text-security: disc;
   text-security: disc;
   text-security: disc;
 }
 }
+
+/* Toasts in the app's own look, for every toast sonner draws: the glass
+   surface and icon tile of the upgrade dialog, and the primary hairline the
+   layout runs along the top of the window. One step more specific than
+   sonner's own `[data-sonner-toast][data-styled='true']` rules, so these win
+   without !important. `--toast-accent` is the one colour that changes with
+   the kind of toast. Sonner uses both pseudo-elements itself (swipe and the
+   hover gap), so the hairline is a background layer instead. */
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] {
+  --toast-accent: var(--primary);
+  background:
+    linear-gradient(
+        to right,
+        var(--toast-accent),
+        color-mix(in oklab, var(--toast-accent) 35%, transparent) 45%,
+        transparent
+      )
+      top / 100% 1px no-repeat,
+    radial-gradient(
+      9rem 5rem at 1.9rem 50%,
+      color-mix(in oklab, var(--toast-accent) 12%, transparent),
+      transparent 70%
+    ),
+    color-mix(in oklab, var(--popover) 90%, transparent);
+  -webkit-backdrop-filter: blur(16px);
+  backdrop-filter: blur(16px);
+  border-color: color-mix(in oklab, var(--border) 70%, transparent);
+  border-radius: calc(var(--radius) + 4px);
+  box-shadow:
+    inset 0 1px 0 rgb(255 255 255 / 0.04),
+    0 18px 40px -16px rgb(0 0 0 / 0.45);
+  padding: 12px 14px;
+  gap: 12px;
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-type='error'] {
+  --toast-accent: var(--destructive);
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-type='warning'] {
+  --toast-accent: oklch(0.75 0.15 70);
+}
+
+/* The icon sits in a tile, like the upgrade dialog's */
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-icon] {
+  width: 2rem;
+  height: 2rem;
+  margin: 0;
+  justify-content: center;
+  border-radius: 0.5rem;
+  color: var(--toast-accent);
+  background: linear-gradient(
+    to bottom,
+    color-mix(in oklab, var(--toast-accent) 16%, transparent),
+    color-mix(in oklab, var(--toast-accent) 5%, transparent)
+  );
+  box-shadow:
+    inset 0 0 0 1px color-mix(in oklab, var(--toast-accent) 28%, transparent),
+    inset 0 1px 0 rgb(255 255 255 / 0.12);
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-icon] svg {
+  margin: 0;
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-title] {
+  font-weight: 600;
+  letter-spacing: -0.01em;
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-description] {
+  color: var(--muted-foreground);
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-button] {
+  background: var(--primary);
+  color: var(--primary-foreground);
+  border-radius: calc(var(--radius) - 2px);
+  font-weight: 500;
+}
+
+[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-cancel] {
+  background: transparent;
+  color: var(--muted-foreground);
+  box-shadow: inset 0 0 0 1px var(--border);
+}

+ 3 - 2
src/app/layout.tsx

@@ -214,9 +214,10 @@ export default async function RootLayout({
                   <GlassModal />
                   <GlassModal />
                   <UpgradeGateDialog />
                   <UpgradeGateDialog />
                   {/* Centred at the bottom, and lifted clear of the mobile
                   {/* Centred at the bottom, and lifted clear of the mobile
-                      bottom nav so a toast never lands on the tab bar. */}
+                      bottom nav so a toast never lands on the tab bar. No
+                      richColors: toasts take the app's own look from
+                      globals.css, tinted by kind, instead of pastel fills. */}
                   <Toaster
                   <Toaster
-                    richColors
                     position="bottom-center"
                     position="bottom-center"
                     mobileOffset={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom))' }}
                     mobileOffset={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom))' }}
                   />
                   />

+ 82 - 0
src/components/notice-toast.tsx

@@ -0,0 +1,82 @@
+'use client'
+
+import type { LucideIcon } from 'lucide-react'
+import { X } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+
+export interface Notice {
+  /** One id per kind of notice, so a second one replaces the first. */
+  id: string
+  icon: LucideIcon
+  title: string
+  description: string
+  actionLabel: string
+  onAction: () => void
+  dismissLabel: string
+}
+
+/**
+ * A notice that needs the person to do one thing: reload, sign in again.
+ *
+ * Drawn in the app's own look rather than sonner's plain card, because these
+ * are the moments the app talks about itself, and they should look like it:
+ * the glass surface and icon tile of the upgrade dialog, and the primary
+ * hairline the layout runs along the top of the window.
+ */
+export function NoticeToast({
+  toastId,
+  icon: Icon,
+  title,
+  description,
+  actionLabel,
+  onAction,
+  dismissLabel,
+}: Omit<Notice, 'id'> & { toastId: string | number }) {
+  return (
+    <div
+      role="status"
+      data-testid="notice-toast"
+      className="glass relative w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl shadow-2xl"
+    >
+      <div
+        aria-hidden
+        className="pointer-events-none absolute inset-0 bg-[radial-gradient(9rem_5rem_at_2.25rem_2rem,color-mix(in_oklab,var(--primary)_14%,transparent),transparent_70%)]"
+      />
+      <div
+        aria-hidden
+        className="pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
+      />
+      <div className="relative flex items-start gap-3 p-4">
+        <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-linear-to-b from-primary/15 to-primary/5 text-primary shadow-[inset_0_1px_0_rgb(255_255_255/0.15)] ring-1 ring-primary/25 ring-inset">
+          <Icon className="h-4 w-4" aria-hidden />
+        </div>
+        <div className="min-w-0 flex-1 pr-5">
+          <p className="font-semibold text-sm tracking-tight">{title}</p>
+          <p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">{description}</p>
+          <Button size="sm" className="mt-3 h-7 px-3 text-xs" onClick={onAction}>
+            {actionLabel}
+          </Button>
+        </div>
+        <button
+          type="button"
+          aria-label={dismissLabel}
+          onClick={() => toast.dismiss(toastId)}
+          className="absolute top-2.5 right-2.5 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+        >
+          <X className="h-3.5 w-3.5" aria-hidden />
+        </button>
+      </div>
+    </div>
+  )
+}
+
+/** Shows a notice until it is acted on or dismissed. */
+export function showNotice({ id, ...notice }: Notice) {
+  return toast.custom((toastId) => <NoticeToast toastId={toastId} {...notice} />, {
+    id,
+    duration: Number.POSITIVE_INFINITY,
+    // The card draws its own surface; sonner's would sit behind it
+    unstyled: true,
+  })
+}

+ 10 - 5
src/components/online-tracker.tsx

@@ -1,7 +1,8 @@
 'use client'
 'use client'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { useEffect } from 'react'
 import { useEffect } from 'react'
-import { toast } from 'sonner'
+import { LogIn } from 'lucide-react'
+import { showNotice } from '@/components/notice-toast'
 import { leaveForSignInNow, signInUrlFor } from '@/lib/signed-out'
 import { leaveForSignInNow, signInUrlFor } from '@/lib/signed-out'
 
 
 const SIGNED_OUT_TOAST_ID = 'signed-out'
 const SIGNED_OUT_TOAST_ID = 'signed-out'
@@ -17,6 +18,7 @@ const SIGNED_OUT_TOAST_ID = 'signed-out'
  */
  */
 export function OnlineTracker() {
 export function OnlineTracker() {
   const t = useTranslations('common.signedOut')
   const t = useTranslations('common.signedOut')
+  const tDismiss = useTranslations('common.updateBanner')
 
 
   useEffect(() => {
   useEffect(() => {
     let lastInputAt = 0
     let lastInputAt = 0
@@ -34,11 +36,14 @@ export function OnlineTracker() {
         return
         return
       }
       }
       // Typing just now: a page change would lose it, so ask instead
       // Typing just now: a page change would lose it, so ask instead
-      toast(t('title'), {
+      showNotice({
         id: SIGNED_OUT_TOAST_ID,
         id: SIGNED_OUT_TOAST_ID,
+        icon: LogIn,
+        title: t('title'),
         description: t('description'),
         description: t('description'),
-        duration: Number.POSITIVE_INFINITY,
-        action: { label: t('signIn'), onClick: goToSignIn },
+        actionLabel: t('signIn'),
+        onAction: goToSignIn,
+        dismissLabel: tDismiss('dismiss'),
       })
       })
     }
     }
 
 
@@ -68,7 +73,7 @@ export function OnlineTracker() {
       window.removeEventListener('keydown', onInput, { capture: true })
       window.removeEventListener('keydown', onInput, { capture: true })
       window.removeEventListener('input', onInput, { capture: true })
       window.removeEventListener('input', onInput, { capture: true })
     }
     }
-  }, [t])
+  }, [t, tDismiss])
 
 
   return null
   return null
 }
 }

+ 10 - 5
src/components/update-watcher.tsx

@@ -2,7 +2,8 @@
 
 
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
 import { useEffect } from 'react'
 import { useEffect } from 'react'
-import { toast } from 'sonner'
+import { RefreshCw } from 'lucide-react'
+import { showNotice } from '@/components/notice-toast'
 
 
 const TOAST_ID = 'app-update'
 const TOAST_ID = 'app-update'
 /**
 /**
@@ -39,6 +40,7 @@ export function isOutdatedActionError(error: unknown): boolean {
  */
  */
 export function UpdateWatcher({ version }: { version: string }) {
 export function UpdateWatcher({ version }: { version: string }) {
   const t = useTranslations('common.appUpdated')
   const t = useTranslations('common.appUpdated')
+  const tDismiss = useTranslations('common.updateBanner')
 
 
   useEffect(() => {
   useEffect(() => {
     if (version === 'development' || version === 'unknown') return
     if (version === 'development' || version === 'unknown') return
@@ -49,11 +51,14 @@ export function UpdateWatcher({ version }: { version: string }) {
     const show = () => {
     const show = () => {
       if (shown) return
       if (shown) return
       shown = true
       shown = true
-      toast(t('title'), {
+      showNotice({
         id: TOAST_ID,
         id: TOAST_ID,
+        icon: RefreshCw,
+        title: t('title'),
         description: t('description'),
         description: t('description'),
-        duration: Number.POSITIVE_INFINITY,
-        action: { label: t('reload'), onClick: () => window.location.reload() },
+        actionLabel: t('reload'),
+        onAction: () => window.location.reload(),
+        dismissLabel: tDismiss('dismiss'),
       })
       })
     }
     }
 
 
@@ -103,7 +108,7 @@ export function UpdateWatcher({ version }: { version: string }) {
       window.removeEventListener('focus', onVisible)
       window.removeEventListener('focus', onVisible)
       window.removeEventListener('unhandledrejection', onRejection)
       window.removeEventListener('unhandledrejection', onRejection)
     }
     }
-  }, [version, t])
+  }, [version, t, tDismiss])
 
 
   return null
   return null
 }
 }

+ 16 - 0
src/features/team/Actions/getMemberSeats.ts

@@ -0,0 +1,16 @@
+'use server'
+
+import { withAuth } from '@/lib/with-auth'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { memberSeats } from '../Lib/memberSeats'
+
+/**
+ * Whether the plan has a seat left for somebody in the office. Read when the
+ * add-person dialog opens, so a full plan says so before anyone types an
+ * address, rather than after Invite.
+ */
+export async function getMemberSeats() {
+  return withAuth(async ({ organizationId }) => memberSeats(organizationId), {
+    requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }],
+  })
+}

+ 5 - 5
src/features/team/Actions/teamActions.ts

@@ -14,7 +14,6 @@ import { setTechnicianStanding } from '../Lib/technicianStanding'
 import { revokeTechnicianCredentials } from '../Lib/revokeTechnicianCredentials'
 import { revokeTechnicianCredentials } from '../Lib/revokeTechnicianCredentials'
 import { emailVerificationRequired } from '@/lib/email-verification-policy'
 import { emailVerificationRequired } from '@/lib/email-verification-policy'
 import {
 import {
-  getFeatures,
   isCloudMode,
   isCloudMode,
   organizationAllowance,
   organizationAllowance,
   FeatureGatedError,
   FeatureGatedError,
@@ -23,6 +22,7 @@ import {
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
 import { canInvite } from '../Lib/invitationRules'
 import { canInvite } from '../Lib/invitationRules'
 import { createAndSendInvitation } from '../Lib/createInvitation'
 import { createAndSendInvitation } from '../Lib/createInvitation'
+import { memberSeats } from '../Lib/memberSeats'
 
 
 export async function getOrganization() {
 export async function getOrganization() {
   return withAuth(
   return withAuth(
@@ -161,13 +161,13 @@ export async function inviteMember(input: unknown) {
       })
       })
       if (!membership) throw new Error("You don't belong to an organization")
       if (!membership) throw new Error("You don't belong to an organization")
 
 
-      const features = await getFeatures(organizationId)
-      const memberCount = await db.organizationMember.count({ where: { organizationId } })
-      if (memberCount >= features.maxUsers) {
+      // The same count the add-person dialog reads before it offers the form
+      const seats = await memberSeats(organizationId)
+      if (seats.full) {
         throw new FeatureGatedError(
         throw new FeatureGatedError(
           'maxUsers',
           'maxUsers',
           'Team member limit reached. Upgrade your plan to add more members.',
           'Team member limit reached. Upgrade your plan to add more members.',
-          features.maxUsers
+          seats.limit
         )
         )
       }
       }
 
 

+ 78 - 6
src/features/team/Components/AddPersonDialog.tsx

@@ -12,6 +12,7 @@ import {
   Mail,
   Mail,
   UserCheck,
   UserCheck,
   Wrench,
   Wrench,
+  Zap,
 } from 'lucide-react'
 } from 'lucide-react'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import {
 import {
@@ -46,6 +47,19 @@ import { CountryPicker } from './CountryPicker'
 import { useTechnicianConnected } from '@/features/team/hooks/useTechnicianConnected'
 import { useTechnicianConnected } from '@/features/team/hooks/useTechnicianConnected'
 import { type IssuedCode, SetupCodeHandoff } from './SetupCodeHandoff'
 import { type IssuedCode, SetupCodeHandoff } from './SetupCodeHandoff'
 import { handleGated } from '@/components/upgrade-gate'
 import { handleGated } from '@/components/upgrade-gate'
+import { getMemberSeats } from '@/features/team/Actions/getMemberSeats'
+import type { MemberSeats } from '@/features/team/Lib/memberSeats'
+import { inviteMemberSchema } from '@/features/team/Schema/teamSchema'
+
+/**
+ * The same address rule the invite action applies. The browser's own
+ * `type="email"` check is looser: it lets `name@gmail` through, the server
+ * then refused it with a raw English validation line, and Invite looked
+ * like it did nothing.
+ */
+function isInviteEmail(value: string): boolean {
+  return inviteMemberSchema.shape.email.safeParse(value.trim()).success
+}
 
 
 /**
 /**
  * Adding somebody to the workshop, whoever they are.
  * Adding somebody to the workshop, whoever they are.
@@ -121,6 +135,7 @@ export function AddPersonDialog({
   onChanged: () => void
   onChanged: () => void
 }) {
 }) {
   const t = useTranslations('settings')
   const t = useTranslations('settings')
+  const tUpgrade = useTranslations('common.upgrade')
   const locale = useLocale()
   const locale = useLocale()
   /**
   /**
    * The country, held as a region code rather than a dial code.
    * The country, held as a region code rather than a dial code.
@@ -150,6 +165,28 @@ export function AddPersonDialog({
     }
     }
   }, [open, fetched, dialCodeProp, rolesProp])
   }, [open, fetched, dialCodeProp, rolesProp])
 
 
+  /**
+   * Seats on the plan, asked every time the dialog opens since a member may
+   * have joined or left since the last time. Until the answer arrives the
+   * office choice works as before and the server still has the last word.
+   */
+  const [seats, setSeats] = useState<MemberSeats | null>(null)
+  useEffect(() => {
+    if (!open) return
+    // The dialog stays mounted while closed, so forget the last answer: a
+    // seat freed since then must not stay locked while this one is pending,
+    // or for the whole opening if the request fails
+    setSeats(null)
+    let cancelled = false
+    getMemberSeats().then((result) => {
+      if (!cancelled && result.success && result.data) setSeats(result.data)
+    })
+    return () => {
+      cancelled = true
+    }
+  }, [open])
+  const officeSeatsFull = seats?.full === true
+
   const dialCode = dialCodeProp ?? fetched?.dialCode ?? ''
   const dialCode = dialCodeProp ?? fetched?.dialCode ?? ''
   const router = useRouter()
   const router = useRouter()
   const roles = rolesProp ?? fetched?.roles ?? []
   const roles = rolesProp ?? fetched?.roles ?? []
@@ -271,6 +308,10 @@ export function AddPersonDialog({
     async (e: React.FormEvent) => {
     async (e: React.FormEvent) => {
       e.preventDefault()
       e.preventDefault()
       if (busy || !email.trim()) return
       if (busy || !email.trim()) return
+      if (!isInviteEmail(email)) {
+        setError(t('team.invalidInviteEmail'))
+        return
+      }
       setBusy(true)
       setBusy(true)
       setError(null)
       setError(null)
 
 
@@ -400,11 +441,22 @@ export function AddPersonDialog({
               hint={t('team.choiceTechnicianHint')}
               hint={t('team.choiceTechnicianHint')}
               onClick={() => choose('technician')}
               onClick={() => choose('technician')}
             />
             />
+            {/* A full plan says so here, before an address is typed. The
+                upgrade dialog it opens is the one Invite would have shown. */}
             <ChoiceButton
             <ChoiceButton
               icon={<Mail className="h-5 w-5" />}
               icon={<Mail className="h-5 w-5" />}
               title={t('team.choiceMember')}
               title={t('team.choiceMember')}
-              hint={t('team.choiceMemberHint')}
-              onClick={() => choose('member')}
+              hint={
+                officeSeatsFull && seats
+                  ? tUpgrade('maxUsers', { limit: seats.limit })
+                  : t('team.choiceMemberHint')
+              }
+              locked={officeSeatsFull}
+              onClick={() =>
+                officeSeatsFull && seats
+                  ? handleGated({ gated: { feature: 'maxUsers', limit: seats.limit } })
+                  : choose('member')
+              }
             />
             />
             <ChoiceButton
             <ChoiceButton
               icon={<CalendarClock className="h-5 w-5" />}
               icon={<CalendarClock className="h-5 w-5" />}
@@ -547,7 +599,10 @@ export function AddPersonDialog({
         )}
         )}
 
 
         {step === 'details' && kind === 'member' && (
         {step === 'details' && kind === 'member' && (
-          <form onSubmit={addMember} className="space-y-4">
+          // noValidate: the address is checked by isInviteEmail, which says
+          // what is wrong in the workshop's language, instead of a browser
+          // bubble that follows a looser rule than the server
+          <form onSubmit={addMember} noValidate className="space-y-4">
             <StepIntro title={t('team.memberDetailsTitle')} blurb={t('team.memberDetailsBlurb')} />
             <StepIntro title={t('team.memberDetailsTitle')} blurb={t('team.memberDetailsBlurb')} />
             <div className="space-y-2">
             <div className="space-y-2">
               <Label htmlFor="member-email">{t('team.inviteByEmail')}</Label>
               <Label htmlFor="member-email">{t('team.inviteByEmail')}</Label>
@@ -555,7 +610,12 @@ export function AddPersonDialog({
                 id="member-email"
                 id="member-email"
                 type="email"
                 type="email"
                 value={email}
                 value={email}
-                onChange={(e) => setEmail(e.target.value)}
+                onChange={(e) => {
+                  setEmail(e.target.value)
+                  if (error) setError(null)
+                }}
+                aria-invalid={error === t('team.invalidInviteEmail') || undefined}
+                aria-describedby={error ? 'member-invite-error' : undefined}
                 placeholder={t('team.inviteEmailPlaceholder')}
                 placeholder={t('team.inviteEmailPlaceholder')}
                 autoComplete="off"
                 autoComplete="off"
                 required
                 required
@@ -612,7 +672,14 @@ export function AddPersonDialog({
               <p className="text-muted-foreground text-xs">{t('team.roleHint')}</p>
               <p className="text-muted-foreground text-xs">{t('team.roleHint')}</p>
             </div>
             </div>
 
 
-            {error && <p className="text-destructive text-sm">{error}</p>}
+            {/* Announced, and tied to the address field, because with the
+                browser's own check turned off this is the only explanation
+                of why Invite stopped */}
+            {error && (
+              <p id="member-invite-error" role="alert" className="text-destructive text-sm">
+                {error}
+              </p>
+            )}
 
 
             <Footer
             <Footer
               backLabel={t('team.stepBack')}
               backLabel={t('team.stepBack')}
@@ -737,23 +804,28 @@ function ChoiceButton({
   title,
   title,
   hint,
   hint,
   onClick,
   onClick,
+  locked = false,
 }: {
 }: {
   icon: React.ReactNode
   icon: React.ReactNode
   title: string
   title: string
   hint: string
   hint: string
   onClick: () => void
   onClick: () => void
+  /** The plan has no room for this answer: marked, and the click offers the upgrade. */
+  locked?: boolean
 }) {
 }) {
   return (
   return (
     <button
     <button
       type="button"
       type="button"
       onClick={onClick}
       onClick={onClick}
+      data-locked={locked || undefined}
       className="flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
       className="flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
     >
     >
       <span className="mt-0.5 text-primary">{icon}</span>
       <span className="mt-0.5 text-primary">{icon}</span>
-      <span className="space-y-0.5">
+      <span className="flex-1 space-y-0.5">
         <span className="block font-medium text-sm">{title}</span>
         <span className="block font-medium text-sm">{title}</span>
         <span className="block text-muted-foreground text-xs">{hint}</span>
         <span className="block text-muted-foreground text-xs">{hint}</span>
       </span>
       </span>
+      {locked && <Zap className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden />}
     </button>
     </button>
   )
   )
 }
 }

+ 27 - 0
src/features/team/Lib/memberSeats.ts

@@ -0,0 +1,27 @@
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+
+/** Seats on the workshop's plan: how many are taken and how many it has. */
+export interface MemberSeats {
+  used: number
+  limit: number
+  /** No seat left, so an office invite would be refused. */
+  full: boolean
+}
+
+export function seatsFrom(used: number, limit: number): MemberSeats {
+  return { used, limit, full: used >= limit }
+}
+
+/**
+ * The one count the invite action enforces and the add-person dialog shows,
+ * so the dialog can never offer a seat the server then refuses. Every member
+ * row counts, technicians included, the same as the invite check always has.
+ */
+export async function memberSeats(organizationId: string): Promise<MemberSeats> {
+  const [features, used] = await Promise.all([
+    getFeatures(organizationId),
+    db.organizationMember.count({ where: { organizationId } }),
+  ])
+  return seatsFrom(used, features.maxUsers)
+}