@eifi1/ui-kit 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -3
- package/dist/auth/accept-invitation.d.ts +90 -0
- package/dist/auth/accept-invitation.js +117 -0
- package/dist/auth/accept-invitation.js.map +1 -0
- package/dist/auth/auth-errors.d.ts +60 -0
- package/dist/auth/auth-errors.js +32 -0
- package/dist/auth/auth-errors.js.map +1 -0
- package/dist/auth/complete-name-dialog.d.ts +71 -0
- package/dist/auth/complete-name-dialog.js +168 -0
- package/dist/auth/complete-name-dialog.js.map +1 -0
- package/dist/auth/email-tag.d.ts +60 -0
- package/dist/auth/email-tag.js +23 -0
- package/dist/auth/email-tag.js.map +1 -0
- package/dist/auth/forgot-password-form.d.ts +79 -0
- package/dist/auth/forgot-password-form.js +120 -0
- package/dist/auth/forgot-password-form.js.map +1 -0
- package/dist/auth/form-rules.d.ts +27 -0
- package/dist/auth/form-rules.js +23 -0
- package/dist/auth/form-rules.js.map +1 -0
- package/dist/auth/new-password-fields.d.ts +38 -0
- package/dist/auth/new-password-fields.js +54 -0
- package/dist/auth/new-password-fields.js.map +1 -0
- package/dist/auth/not-found-page.d.ts +57 -0
- package/dist/auth/not-found-page.js +40 -0
- package/dist/auth/not-found-page.js.map +1 -0
- package/dist/auth/register-form.d.ts +86 -0
- package/dist/auth/register-form.js +268 -0
- package/dist/auth/register-form.js.map +1 -0
- package/dist/auth/reset-password-form.d.ts +124 -0
- package/dist/auth/reset-password-form.js +164 -0
- package/dist/auth/reset-password-form.js.map +1 -0
- package/dist/auth/sign-in-form.d.ts +243 -0
- package/dist/auth/sign-in-form.js +345 -0
- package/dist/auth/sign-in-form.js.map +1 -0
- package/dist/auth/status-parts.d.ts +78 -0
- package/dist/auth/status-parts.js +83 -0
- package/dist/auth/status-parts.js.map +1 -0
- package/dist/auth/verify-email.d.ts +121 -0
- package/dist/auth/verify-email.js +207 -0
- package/dist/auth/verify-email.js.map +1 -0
- package/dist/chart.d.ts +17 -2
- package/dist/components/amount-input.d.ts +18 -2
- package/dist/components/button-group.d.ts +18 -2
- package/dist/components/calculator.d.ts +18 -2
- package/dist/components/confirm-dialog.d.ts +18 -2
- package/dist/components/copy-button.d.ts +18 -2
- package/dist/components/danger-confirm.d.ts +18 -2
- package/dist/components/facing-pair.d.ts +18 -2
- package/dist/components/file-button.d.ts +18 -2
- package/dist/components/file-dropzone.d.ts +18 -2
- package/dist/components/form-actions.d.ts +18 -2
- package/dist/components/iban-input.d.ts +18 -2
- package/dist/components/language-select.d.ts +18 -9
- package/dist/components/legal-page.d.ts +18 -2
- package/dist/components/legal.d.ts +18 -2
- package/dist/components/money-field.d.ts +18 -2
- package/dist/components/number-field.d.ts +18 -2
- package/dist/components/number-input.d.ts +18 -2
- package/dist/components/numpad-sheet.d.ts +18 -2
- package/dist/components/phone-input.d.ts +18 -2
- package/dist/components/series-chart.d.ts +18 -2
- package/dist/components/settings-fields.d.ts +15 -2
- package/dist/components/share-card.d.ts +18 -2
- package/dist/components/text-link.d.ts +18 -2
- package/dist/components/time-input.d.ts +18 -2
- package/dist/components/ui.d.ts +17 -1
- package/dist/components/user-avatar.d.ts +16 -2
- package/dist/components/user-avatar.js +7 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +18 -2
- package/dist/feedback/feedback-context.d.ts +18 -2
- package/dist/feedback/feedback-crash.d.ts +18 -2
- package/dist/feedback/feedback-dialog.d.ts +18 -2
- package/dist/feedback/feedback-inbox.d.ts +18 -2
- package/dist/feedback/feedback-labels.d.ts +18 -2
- package/dist/feedback/feedback-menu.d.ts +18 -2
- package/dist/feedback/feedback-record.d.ts +18 -2
- package/dist/feedback/feedback-row-detail.d.ts +18 -2
- package/dist/feedback/feedback-status-undo.d.ts +18 -2
- package/dist/feedback/feedback-submit.d.ts +18 -2
- package/dist/feedback/feedback-swipe.d.ts +18 -2
- package/dist/feedback/feedback-table.d.ts +18 -2
- package/dist/feedback/feedback-thread.d.ts +18 -2
- package/dist/feedback.d.ts +18 -2
- package/dist/hooks/use-file-drop.d.ts +18 -2
- package/dist/i18n/defaults.d.ts +18 -2
- package/dist/i18n/defaults.js +18 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +18 -2
- package/dist/i18n/german.js +137 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +18 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +13 -201
- package/dist/i18n/locales/de-CH.d.ts +18 -2
- package/dist/i18n/locales/en.d.ts +18 -2
- package/dist/i18n/locales/es.d.ts +18 -2
- package/dist/i18n/locales/es.js +137 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +18 -2
- package/dist/i18n/locales/fr.js +139 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +18 -2
- package/dist/i18n/locales/hu.js +139 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +18 -2
- package/dist/i18n/locales/it.js +137 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +18 -2
- package/dist/i18n/locales/zh.js +137 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +18 -2
- package/dist/i18n/review.js +9 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/person-name.d.ts +69 -0
- package/dist/lib/person-name.js +65 -0
- package/dist/lib/person-name.js.map +1 -0
- package/dist/{kit-labels-Du0uHBq4.d.ts → register-form-V1OeNlMr.d.ts} +383 -2
- package/dist/rhf/fields.d.ts +18 -2
- package/dist/rhf/form.d.ts +18 -2
- package/dist/rhf.d.ts +18 -2
- package/dist/shell/app-shell.d.ts +18 -2
- package/dist/shell/company-switcher.d.ts +59 -0
- package/dist/shell/company-switcher.js +85 -0
- package/dist/shell/company-switcher.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +18 -2
- package/dist/shell/topbar-action-menu.d.ts +1 -0
- package/dist/shell.d.ts +12 -2
- package/dist/wizard/stepper-nav.d.ts +18 -2
- package/dist/wizard.d.ts +18 -2
- package/package.json +1 -1
- package/src/auth/accept-invitation.tsx +227 -0
- package/src/auth/auth-errors.ts +105 -0
- package/src/auth/complete-name-dialog.tsx +253 -0
- package/src/auth/email-tag.ts +75 -0
- package/src/auth/forgot-password-form.tsx +213 -0
- package/src/auth/form-rules.ts +40 -0
- package/src/auth/new-password-fields.tsx +85 -0
- package/src/auth/not-found-page.tsx +98 -0
- package/src/auth/register-form.tsx +459 -0
- package/src/auth/reset-password-form.tsx +337 -0
- package/src/auth/sign-in-form.tsx +687 -0
- package/src/auth/status-parts.tsx +138 -0
- package/src/auth/verify-email.tsx +358 -0
- package/src/components/user-avatar.tsx +22 -4
- package/src/i18n/defaults.ts +18 -0
- package/src/i18n/german.ts +143 -0
- package/src/i18n/kit-labels.tsx +27 -0
- package/src/i18n/locales/es.ts +144 -1
- package/src/i18n/locales/fr.ts +147 -0
- package/src/i18n/locales/hu.ts +147 -1
- package/src/i18n/locales/it.ts +143 -0
- package/src/i18n/locales/zh.ts +138 -0
- package/src/i18n/review.ts +8 -0
- package/src/index.ts +53 -0
- package/src/lib/person-name.ts +157 -0
- package/src/shell/company-switcher.tsx +140 -0
package/src/i18n/locales/it.ts
CHANGED
|
@@ -838,6 +838,149 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
|
|
|
838
838
|
},
|
|
839
839
|
},
|
|
840
840
|
},
|
|
841
|
+
// 0.29.0 — docs/auth-harmonization.md: keksdose's reviewed Italian (auth.*,
|
|
842
|
+
// verify_email, error.not_found_*, budget_share.join_*), kastlan's for the names and
|
|
843
|
+
// the 2FA prompt; written fresh where neither app had the sentence. Formal, "Lei" and
|
|
844
|
+
// "Suo" capitalised; headings addressed formally ("Scelga", not keksdose's "Scegli").
|
|
845
|
+
signIn: {
|
|
846
|
+
email: "Email",
|
|
847
|
+
password: "Password",
|
|
848
|
+
submit: "Accedi",
|
|
849
|
+
passkey: "Accedi con una passkey",
|
|
850
|
+
forgotPassword: "Password dimenticata?",
|
|
851
|
+
noAccount: "Non ha ancora un account?",
|
|
852
|
+
register: "Crea account",
|
|
853
|
+
invalidCredentials: "L’indirizzo email o la password non sono corretti.",
|
|
854
|
+
failed: "Accesso non riuscito. Riprovi.",
|
|
855
|
+
passkeyFailed: "Accesso con passkey non riuscito.",
|
|
856
|
+
deactivatedHint: "Account disattivato? Scriva a {contact}.",
|
|
857
|
+
// "Account creato con…" rather than "Si è registrato con…", which needs a gender.
|
|
858
|
+
tagHint: (taggedAddress) => `Account creato con ${taggedAddress}? Usi quell’indirizzo.`,
|
|
859
|
+
twoFactorTitle: "Autenticazione a due fattori",
|
|
860
|
+
twoFactorIntro: "Inserisca il codice mostrato dalla Sua app di autenticazione.",
|
|
861
|
+
code: "Codice 2FA",
|
|
862
|
+
verify: "Verifica",
|
|
863
|
+
codeInvalid: "Codice 2FA non valido.",
|
|
864
|
+
setPasswordTitle: "Scelga una nuova password",
|
|
865
|
+
setPasswordIntro:
|
|
866
|
+
"Per questo account è stata richiesta una nuova password. Ne scelga una e accederà subito.",
|
|
867
|
+
newPassword: "Nuova password",
|
|
868
|
+
confirmPassword: "Ripeti la nuova password",
|
|
869
|
+
passwordMismatch: "Le password non coincidono.",
|
|
870
|
+
setPasswordSubmit: "Imposta la password e accedi",
|
|
871
|
+
setPasswordFailed: "Impossibile impostare la password.",
|
|
872
|
+
expired: "Questo accesso è scaduto. Acceda di nuovo.",
|
|
873
|
+
backToSignIn: "Torna all’accesso",
|
|
874
|
+
},
|
|
875
|
+
register: {
|
|
876
|
+
firstName: "Nome",
|
|
877
|
+
lastName: "Cognome",
|
|
878
|
+
email: "Email",
|
|
879
|
+
emailTagUse: (address) => `Usa ${address}`,
|
|
880
|
+
emailTagHint:
|
|
881
|
+
"Molti provider recapitano nome+tag@… nella stessa casella, così la posta di questa app è filtrabile e riconoscibile. Verifichi che il Suo lo faccia prima di farci affidamento: accederebbe con l’indirizzo con il tag.",
|
|
882
|
+
invitedEmailHint: "L’indirizzo a cui è stato inviato il Suo invito.",
|
|
883
|
+
invitedTagNote: "L’indirizzo con il tag riceve una propria email di conferma.",
|
|
884
|
+
password: "Password",
|
|
885
|
+
confirmPassword: "Ripeti la password",
|
|
886
|
+
passwordMismatch: "Le password non coincidono.",
|
|
887
|
+
language: "Lingua",
|
|
888
|
+
submit: "Crea account",
|
|
889
|
+
haveAccount: "Ha già un account?",
|
|
890
|
+
signIn: "Accedi",
|
|
891
|
+
emailTaken: "Esiste già un account con questo indirizzo email.",
|
|
892
|
+
registrationClosed:
|
|
893
|
+
"I nuovi account si creano solo su invito. Chieda al gestore di invitare il Suo indirizzo email.",
|
|
894
|
+
invitationInvalid: "Questo link di invito non è valido.",
|
|
895
|
+
invitationExpired: "Questo invito è scaduto. Ne chieda uno nuovo.",
|
|
896
|
+
failed: "Registrazione non riuscita. Riprovi.",
|
|
897
|
+
},
|
|
898
|
+
completeName: {
|
|
899
|
+
title: "Completi il Suo nome",
|
|
900
|
+
description:
|
|
901
|
+
"Nome e cognome ora vengono richiesti separatamente. Controlli quanto è già compilato e aggiunga ciò che manca.",
|
|
902
|
+
firstName: "Nome",
|
|
903
|
+
lastName: "Cognome",
|
|
904
|
+
save: "Salva",
|
|
905
|
+
later: "Più tardi",
|
|
906
|
+
failed: "Impossibile salvare il Suo nome. Riprovi.",
|
|
907
|
+
},
|
|
908
|
+
forgotPassword: {
|
|
909
|
+
title: "Password dimenticata",
|
|
910
|
+
intro:
|
|
911
|
+
"Inserisca l’indirizzo email del Suo account. Le invieremo un link per scegliere una nuova password.",
|
|
912
|
+
email: "Email",
|
|
913
|
+
submit: "Invia il link",
|
|
914
|
+
sent: (email) => `Se esiste un account per ${email}, il link è in arrivo.`,
|
|
915
|
+
sentHint: "Il link è valido per un’ora e funziona una sola volta. Controlli anche la cartella spam.",
|
|
916
|
+
backToSignIn: "Torna all’accesso",
|
|
917
|
+
error: "Richiesta non riuscita. Riprovi più tardi.",
|
|
918
|
+
},
|
|
919
|
+
resetPassword: {
|
|
920
|
+
title: "Scelga una nuova password",
|
|
921
|
+
checking: "Verifica del link…",
|
|
922
|
+
intro: (email) => `Sta impostando una nuova password per ${email}.`,
|
|
923
|
+
newPassword: "Nuova password",
|
|
924
|
+
confirmPassword: "Ripeti la nuova password",
|
|
925
|
+
mismatch: "Le password non coincidono.",
|
|
926
|
+
submit: "Salva password",
|
|
927
|
+
failed: "Impossibile cambiare la password.",
|
|
928
|
+
noToken: "Questo link è incompleto.",
|
|
929
|
+
invalid: "Questo link non è valido o è scaduto.",
|
|
930
|
+
invalidHint:
|
|
931
|
+
"I link sono validi per un’ora e funzionano una sola volta. Ne richieda semplicemente uno nuovo.",
|
|
932
|
+
requestNew: "Richiedi un nuovo link",
|
|
933
|
+
success: "La Sua password è stata cambiata. Ora può accedere con quella.",
|
|
934
|
+
sessionsEnded: "I dispositivi connessi sono stati disconnessi: lì dovrà accedere di nuovo.",
|
|
935
|
+
signIn: "Vai all’accesso",
|
|
936
|
+
},
|
|
937
|
+
verifyEmail: {
|
|
938
|
+
title: "Confermi il Suo indirizzo email",
|
|
939
|
+
verifying: "Conferma in corso…",
|
|
940
|
+
verified: "Il Suo indirizzo email è confermato.",
|
|
941
|
+
continue: "Continua",
|
|
942
|
+
invalid: "Questo link di conferma non è valido",
|
|
943
|
+
expired: "Questo link di conferma è scaduto",
|
|
944
|
+
noToken: "A questo link manca il token di conferma.",
|
|
945
|
+
invalidHint: "Il link potrebbe essere scaduto o già stato usato.",
|
|
946
|
+
requestInApp: "Può richiederne uno nuovo dall’app.",
|
|
947
|
+
requestHere: "Può richiederne uno nuovo qui.",
|
|
948
|
+
resend: "Invia di nuovo",
|
|
949
|
+
resendIn: (seconds) => `Invia di nuovo tra ${n(seconds)} s`,
|
|
950
|
+
resent: "Email di conferma inviata",
|
|
951
|
+
resendError: "Impossibile inviare l’email di conferma",
|
|
952
|
+
banner: "Confermi il Suo indirizzo email.",
|
|
953
|
+
dismiss: "Non ora",
|
|
954
|
+
},
|
|
955
|
+
notFound: {
|
|
956
|
+
title: "Pagina non trovata",
|
|
957
|
+
body: "Questo indirizzo non esiste (più). Forse è un errore di battitura, oppure la pagina è stata spostata.",
|
|
958
|
+
home: "Vai alla pagina iniziale",
|
|
959
|
+
app: "Torna all’app",
|
|
960
|
+
},
|
|
961
|
+
acceptInvitation: {
|
|
962
|
+
title: "Accetta l’invito",
|
|
963
|
+
accepting: "Adesione in corso…",
|
|
964
|
+
accepted: "Invito accettato.",
|
|
965
|
+
// "Ora fa parte di…" rather than "Si è unito/a a…", which needs a gender.
|
|
966
|
+
joined: (name) => `Ora fa parte di ${name}.`,
|
|
967
|
+
continue: "Continua",
|
|
968
|
+
invalid: "Impossibile unirsi con questo link",
|
|
969
|
+
expired: "Questo invito è scaduto",
|
|
970
|
+
noToken: "A questo link manca il token di invito.",
|
|
971
|
+
askAgain: "Chieda a chi ha inviato l’invito di mandarne uno nuovo.",
|
|
972
|
+
signInFirst: "Acceda per accettare questo invito",
|
|
973
|
+
signInHint: "Usi l’account a cui è stato inviato l’invito.",
|
|
974
|
+
signIn: "Accedi",
|
|
975
|
+
register: "Crea account",
|
|
976
|
+
},
|
|
977
|
+
companySwitcher: {
|
|
978
|
+
switchCompany: "Cambia azienda",
|
|
979
|
+
heading: "Aziende",
|
|
980
|
+
current: (name) => `Azienda: ${name}`,
|
|
981
|
+
currentMark: "(attuale)",
|
|
982
|
+
switching: "Cambio azienda in corso…",
|
|
983
|
+
},
|
|
841
984
|
characterCount: {
|
|
842
985
|
count: (used, max) => `${n(used)} di ${n(max)} ${plural(max, "carattere", "caratteri")}`,
|
|
843
986
|
remaining: (left) =>
|
package/src/i18n/locales/zh.ts
CHANGED
|
@@ -814,6 +814,144 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
|
|
|
814
814
|
},
|
|
815
815
|
},
|
|
816
816
|
},
|
|
817
|
+
// 0.29.0 — docs/auth-harmonization.md: Kurvenschmiede's reviewed Chinese (auth.*,
|
|
818
|
+
// notfound) where it says the same, brought into line with the kit's English and this
|
|
819
|
+
// catalogue's words ("通行密钥", "双重验证"); written fresh where Kurvenschmiede has no
|
|
820
|
+
// such text yet. Full-width punctuation; an interpolated address or name gets a space
|
|
821
|
+
// either side, none before full-width punctuation. It goes back to Kurvenschmiede for
|
|
822
|
+
// review.
|
|
823
|
+
signIn: {
|
|
824
|
+
email: "电子邮箱",
|
|
825
|
+
password: "密码",
|
|
826
|
+
submit: "登录",
|
|
827
|
+
passkey: "使用通行密钥登录",
|
|
828
|
+
forgotPassword: "忘记密码?",
|
|
829
|
+
noAccount: "还没有账户?",
|
|
830
|
+
register: "创建账户",
|
|
831
|
+
invalidCredentials: "电子邮箱或密码不正确。",
|
|
832
|
+
failed: "登录失败,请重试。",
|
|
833
|
+
passkeyFailed: "通行密钥登录失败。",
|
|
834
|
+
deactivatedHint: "账户已停用?请联系 {contact}。",
|
|
835
|
+
tagHint: (taggedAddress) => `注册时用的是 ${taggedAddress} 吗?请使用该地址。`,
|
|
836
|
+
twoFactorTitle: "双重验证",
|
|
837
|
+
twoFactorIntro: "请输入身份验证器应用显示的验证码。",
|
|
838
|
+
code: "验证码",
|
|
839
|
+
verify: "验证",
|
|
840
|
+
codeInvalid: "验证码无效。",
|
|
841
|
+
setPasswordTitle: "设置新密码",
|
|
842
|
+
setPasswordIntro: "已要求为此账户设置新密码。设置之后即可直接继续使用。",
|
|
843
|
+
newPassword: "新密码",
|
|
844
|
+
confirmPassword: "再次输入新密码",
|
|
845
|
+
passwordMismatch: "两次输入的密码不一致。",
|
|
846
|
+
setPasswordSubmit: "设置密码并登录",
|
|
847
|
+
setPasswordFailed: "无法设置密码。",
|
|
848
|
+
expired: "本次登录已过期,请重新登录。",
|
|
849
|
+
backToSignIn: "返回登录",
|
|
850
|
+
},
|
|
851
|
+
register: {
|
|
852
|
+
firstName: "名字",
|
|
853
|
+
lastName: "姓氏",
|
|
854
|
+
email: "电子邮箱",
|
|
855
|
+
emailTagUse: (address) => `使用 ${address}`,
|
|
856
|
+
emailTagHint:
|
|
857
|
+
"许多邮件服务商会把发往 name+tag@… 的邮件投递到同一个收件箱,这样本应用发来的邮件便于筛选和追溯。请先确认您的服务商支持这一点再使用:之后您需要用带标签的地址登录。",
|
|
858
|
+
invitedEmailHint: "接收邀请的地址。",
|
|
859
|
+
invitedTagNote: "带标签的地址会单独收到一封确认邮件。",
|
|
860
|
+
password: "密码",
|
|
861
|
+
confirmPassword: "再次输入密码",
|
|
862
|
+
passwordMismatch: "两次输入的密码不一致。",
|
|
863
|
+
language: "语言",
|
|
864
|
+
submit: "创建账户",
|
|
865
|
+
haveAccount: "已有账户?",
|
|
866
|
+
signIn: "登录",
|
|
867
|
+
emailTaken: "该电子邮箱地址已有账户。",
|
|
868
|
+
registrationClosed: "新账户仅限受邀注册。请联系运营者,为您的电子邮箱地址发送邀请。",
|
|
869
|
+
invitationInvalid: "此邀请链接无效。",
|
|
870
|
+
invitationExpired: "此邀请已过期,请索取新的邀请。",
|
|
871
|
+
failed: "注册失败,请重试。",
|
|
872
|
+
},
|
|
873
|
+
completeName: {
|
|
874
|
+
title: "完善您的姓名",
|
|
875
|
+
description: "现在需要分别填写名字和姓氏。请检查已填写的内容,并补充缺少的部分。",
|
|
876
|
+
firstName: "名字",
|
|
877
|
+
lastName: "姓氏",
|
|
878
|
+
save: "保存",
|
|
879
|
+
later: "稍后",
|
|
880
|
+
failed: "无法保存您的姓名,请重试。",
|
|
881
|
+
},
|
|
882
|
+
forgotPassword: {
|
|
883
|
+
title: "忘记密码",
|
|
884
|
+
intro: "请输入您账户的电子邮箱地址。我们会向您发送一个设置新密码的链接。",
|
|
885
|
+
email: "电子邮箱",
|
|
886
|
+
submit: "发送链接",
|
|
887
|
+
sent: (email) => `如果 ${email} 有账户,链接正在发送中。`,
|
|
888
|
+
sentHint: "该链接有效期一小时,且仅可使用一次。也请查看垃圾邮件文件夹。",
|
|
889
|
+
backToSignIn: "返回登录",
|
|
890
|
+
error: "请求失败,请稍后重试。",
|
|
891
|
+
},
|
|
892
|
+
resetPassword: {
|
|
893
|
+
title: "设置新密码",
|
|
894
|
+
checking: "正在检查链接…",
|
|
895
|
+
intro: (email) => `您正在为 ${email} 设置新密码。`,
|
|
896
|
+
newPassword: "新密码",
|
|
897
|
+
confirmPassword: "再次输入新密码",
|
|
898
|
+
mismatch: "两次输入的密码不一致。",
|
|
899
|
+
submit: "保存密码",
|
|
900
|
+
failed: "无法修改密码。",
|
|
901
|
+
noToken: "此链接不完整。",
|
|
902
|
+
invalid: "此链接无效或已过期。",
|
|
903
|
+
invalidHint: "链接有效期一小时,且仅可使用一次。重新申请一个即可。",
|
|
904
|
+
requestNew: "申请新链接",
|
|
905
|
+
success: "您的密码已修改,现在可以用它登录了。",
|
|
906
|
+
sessionsEnded: "已登录的设备均已退出登录——您需要在这些设备上重新登录。",
|
|
907
|
+
signIn: "前往登录",
|
|
908
|
+
},
|
|
909
|
+
verifyEmail: {
|
|
910
|
+
title: "确认您的电子邮箱地址",
|
|
911
|
+
verifying: "正在确认…",
|
|
912
|
+
verified: "您的电子邮箱地址已确认。",
|
|
913
|
+
continue: "继续",
|
|
914
|
+
invalid: "此确认链接无效",
|
|
915
|
+
expired: "此确认链接已过期",
|
|
916
|
+
noToken: "此链接缺少确认令牌。",
|
|
917
|
+
invalidHint: "该链接可能已过期或已被使用。",
|
|
918
|
+
requestInApp: "您可以在应用内申请新的链接。",
|
|
919
|
+
requestHere: "您可以在此申请新的链接。",
|
|
920
|
+
resend: "重新发送",
|
|
921
|
+
resendIn: (seconds) => `${n(seconds)} 秒后可重新发送`,
|
|
922
|
+
resent: "确认邮件已发送",
|
|
923
|
+
resendError: "无法发送确认邮件",
|
|
924
|
+
banner: "请确认您的电子邮箱地址。",
|
|
925
|
+
dismiss: "暂不",
|
|
926
|
+
},
|
|
927
|
+
notFound: {
|
|
928
|
+
title: "未找到页面",
|
|
929
|
+
body: "此地址不存在(或已不存在)。可能是输入有误,也可能是页面已被移动。",
|
|
930
|
+
home: "前往首页",
|
|
931
|
+
app: "返回应用",
|
|
932
|
+
},
|
|
933
|
+
acceptInvitation: {
|
|
934
|
+
title: "接受邀请",
|
|
935
|
+
accepting: "正在加入…",
|
|
936
|
+
accepted: "已接受邀请。",
|
|
937
|
+
joined: (name) => `您已加入 ${name}。`,
|
|
938
|
+
continue: "继续",
|
|
939
|
+
invalid: "无法通过此链接加入",
|
|
940
|
+
expired: "此邀请已过期",
|
|
941
|
+
noToken: "此链接缺少邀请令牌。",
|
|
942
|
+
askAgain: "请让邀请您的人重新发送一份邀请。",
|
|
943
|
+
signInFirst: "登录以接受此邀请",
|
|
944
|
+
signInHint: "请使用收到邀请的账户登录。",
|
|
945
|
+
signIn: "登录",
|
|
946
|
+
register: "创建账户",
|
|
947
|
+
},
|
|
948
|
+
companySwitcher: {
|
|
949
|
+
switchCompany: "切换公司",
|
|
950
|
+
heading: "公司",
|
|
951
|
+
current: (name) => `公司:${name}`,
|
|
952
|
+
currentMark: "(当前)",
|
|
953
|
+
switching: "正在切换公司…",
|
|
954
|
+
},
|
|
817
955
|
characterCount: {
|
|
818
956
|
count: (used, max) => `已输入 ${n(used)}/${n(max)} 个字符`,
|
|
819
957
|
remaining: (left) => `还可输入 ${n(left)} 个字符`,
|
package/src/i18n/review.ts
CHANGED
|
@@ -294,4 +294,12 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
|
|
|
294
294
|
"legal.sections.terms.language.body": operatorSample,
|
|
295
295
|
"legal.translation.note": operatorSample,
|
|
296
296
|
"legal.translation.show": operatorSample,
|
|
297
|
+
// 0.29.0: the sign-in and sign-up parts (docs/auth-harmonization.md).
|
|
298
|
+
"signIn.tagHint": [["{{taggedAddress}}"]],
|
|
299
|
+
"register.emailTagUse": [["{{address}}"]],
|
|
300
|
+
"forgotPassword.sent": [["{{email}}"]],
|
|
301
|
+
"resetPassword.intro": [["{{email}}"]],
|
|
302
|
+
"verifyEmail.resendIn": [[30]],
|
|
303
|
+
"acceptInvitation.joined": [["{{name}}"]],
|
|
304
|
+
"companySwitcher.current": [["{{name}}"]],
|
|
297
305
|
};
|
package/src/index.ts
CHANGED
|
@@ -487,6 +487,59 @@ export type {
|
|
|
487
487
|
LegalFramedSectionLabels,
|
|
488
488
|
} from "./components/legal";
|
|
489
489
|
export { LegalPage, LegalKitSection, LegalAcceptCheckbox, LEGAL_SKELETON } from "./components/legal-page";
|
|
490
|
+
|
|
491
|
+
// 0.29.0 (docs/auth-harmonization.md): sign-in, sign-up and the signed-out pages — forms
|
|
492
|
+
// that take callbacks and never send a request themselves — the person-name order of each
|
|
493
|
+
// language, and kastlan's company switcher.
|
|
494
|
+
export { SignInForm, DEFAULT_SIGN_IN_LABELS } from "./auth/sign-in-form";
|
|
495
|
+
export type {
|
|
496
|
+
SignInFormProps,
|
|
497
|
+
SignInLabels,
|
|
498
|
+
SignInAnswer,
|
|
499
|
+
SignInStep,
|
|
500
|
+
SignInAction,
|
|
501
|
+
SignInCredentials,
|
|
502
|
+
SignInCodeValues,
|
|
503
|
+
SignInNewPasswordValues,
|
|
504
|
+
} from "./auth/sign-in-form";
|
|
505
|
+
export { RegisterForm, DEFAULT_REGISTER_LABELS } from "./auth/register-form";
|
|
506
|
+
export type { RegisterFormProps, RegisterLabels, RegisterValues } from "./auth/register-form";
|
|
507
|
+
export { CompleteNameDialog, DEFAULT_COMPLETE_NAME_LABELS } from "./auth/complete-name-dialog";
|
|
508
|
+
export type { CompleteNameDialogProps, CompleteNameLabels, CompleteNameValues } from "./auth/complete-name-dialog";
|
|
509
|
+
export { taggedEmail } from "./auth/email-tag";
|
|
510
|
+
export { isAuthError, authErrorCode } from "./auth/auth-errors";
|
|
511
|
+
export type { AuthErrorCode } from "./auth/auth-errors";
|
|
512
|
+
export { ForgotPasswordForm, DEFAULT_FORGOT_PASSWORD_LABELS } from "./auth/forgot-password-form";
|
|
513
|
+
export type { ForgotPasswordFormProps, ForgotPasswordLabels } from "./auth/forgot-password-form";
|
|
514
|
+
export { ResetPasswordForm, DEFAULT_RESET_PASSWORD_LABELS } from "./auth/reset-password-form";
|
|
515
|
+
export type {
|
|
516
|
+
ResetPasswordFormProps,
|
|
517
|
+
ResetPasswordLabels,
|
|
518
|
+
ResetPasswordCheck,
|
|
519
|
+
ResetPasswordValues,
|
|
520
|
+
ResetPasswordResult,
|
|
521
|
+
} from "./auth/reset-password-form";
|
|
522
|
+
export { VerifyEmailStatus, EmailVerificationBanner, DEFAULT_VERIFY_EMAIL_LABELS } from "./auth/verify-email";
|
|
523
|
+
export type {
|
|
524
|
+
VerifyEmailStatusProps,
|
|
525
|
+
EmailVerificationBannerProps,
|
|
526
|
+
VerifyEmailLabels,
|
|
527
|
+
VerifyEmailFailure,
|
|
528
|
+
} from "./auth/verify-email";
|
|
529
|
+
export { NotFoundPage, DEFAULT_NOT_FOUND_LABELS } from "./auth/not-found-page";
|
|
530
|
+
export type { NotFoundPageProps, NotFoundLabels } from "./auth/not-found-page";
|
|
531
|
+
export { AcceptInvitation, DEFAULT_ACCEPT_INVITATION_LABELS } from "./auth/accept-invitation";
|
|
532
|
+
export type {
|
|
533
|
+
AcceptInvitationProps,
|
|
534
|
+
AcceptInvitationLabels,
|
|
535
|
+
AcceptInvitationResult,
|
|
536
|
+
AcceptInvitationFailure,
|
|
537
|
+
} from "./auth/accept-invitation";
|
|
538
|
+
export type { AuthHeadingLevel } from "./auth/status-parts";
|
|
539
|
+
export { formatPersonName, personInitials } from "./lib/person-name";
|
|
540
|
+
export type { PersonName } from "./lib/person-name";
|
|
541
|
+
export { CompanySwitcher, DEFAULT_COMPANY_SWITCHER_LABELS } from "./shell/company-switcher";
|
|
542
|
+
export type { CompanySwitcherProps, CompanySwitcherLabels, CompanySwitcherCompany } from "./shell/company-switcher";
|
|
490
543
|
export type {
|
|
491
544
|
LegalPageProps,
|
|
492
545
|
LegalKitSectionProps,
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A person's name as the reader's language writes it — the one rule of
|
|
3
|
+
* docs/auth-harmonization.md §3.2 for every place a name is shown: the top bar, the
|
|
4
|
+
* account menu, user lists, avatars, feedback, the greeting in a mail.
|
|
5
|
+
*
|
|
6
|
+
* WHY A FUNCTION AND NOT `${first} ${last}`. The three apps move from one free-text
|
|
7
|
+
* `display_name` to `first_name` + `last_name` (§2.3), and the moment a name has two
|
|
8
|
+
* parts, their ORDER is a language rule, not a data rule:
|
|
9
|
+
*
|
|
10
|
+
* - "First Last" in de-CH, en, fr, it and es — "Ada Example";
|
|
11
|
+
* - **"Last First" in hu** — Hungarian puts the family name first: "Example Ada";
|
|
12
|
+
* - **"LastFirst" with no space in zh** — a Chinese name is written family name first
|
|
13
|
+
* and run together: 李 + 小龙 → "李小龙". Only a name written in CJK characters,
|
|
14
|
+
* though: BOTH parts must hold Han (or kana, or hangul) characters. A Latin name read
|
|
15
|
+
* in Chinese stays as it is written — "Ada Lovelace", not "LovelaceAda" (the
|
|
16
|
+
* coordinator's refinement of §3.2, 2026-10-06).
|
|
17
|
+
*
|
|
18
|
+
* WHOSE language (§3.2, §10.6): on screen the READER's, so this takes the locale of the
|
|
19
|
+
* UI, never the locale stored with the person. A mail uses the recipient's and a frozen
|
|
20
|
+
* stamp the author's — that is server-kit's `full_name(first, last, locale)`, which
|
|
21
|
+
* follows exactly these rules so a name reads the same in the app and in its mails.
|
|
22
|
+
*
|
|
23
|
+
* NOTHING IS GUESSED. Both parts are trimmed, and an empty part simply drops out: a
|
|
24
|
+
* migrated keksdose or Kurvenschmiede user keeps the whole old display name as the
|
|
25
|
+
* first name and an empty last name until they complete it (§3.3), and "Ada Example"
|
|
26
|
+
* with no last name must still read "Ada Example" — not "Ada Example " and not a split
|
|
27
|
+
* at the space. Inner spaces are the person's own and stay as typed.
|
|
28
|
+
*
|
|
29
|
+
* Only the language subtag counts ("de-CH" → "de", "zh-Hant-TW" → "zh"), and a language
|
|
30
|
+
* this does not know reads "First Last", the order of the kit's five other languages.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The two parts of a person's name. Either may be missing or blank: a migrated row
|
|
34
|
+
* has an empty `last` until it is completed (§3.3), and APIs send `null` for "none". */
|
|
35
|
+
export interface PersonName {
|
|
36
|
+
/** The given name — `first_name`. */
|
|
37
|
+
first?: string | null;
|
|
38
|
+
/** The family name — `last_name`. */
|
|
39
|
+
last?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type NameOrder = "given-first" | "family-first" | "family-first-joined";
|
|
43
|
+
|
|
44
|
+
/** A part written in CJK characters: Han, and kana or hangul where they occur (a
|
|
45
|
+
* Japanese or Korean name in a Chinese UI). One such character is enough. */
|
|
46
|
+
const CJK = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u;
|
|
47
|
+
|
|
48
|
+
function language(locale: string | undefined): string {
|
|
49
|
+
return (locale ?? "").split(/[-_]/)[0]!.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The order the reader's language writes THIS name in. See the module note: hu puts the
|
|
54
|
+
* family name first for every name; zh runs a CJK name together family-first and leaves
|
|
55
|
+
* any other name as it is written; the rest write the given name first. A part that is
|
|
56
|
+
* empty passes the CJK test, so a one-part name orders by the part it has. Called with
|
|
57
|
+
* at least one part filled in.
|
|
58
|
+
*/
|
|
59
|
+
function nameOrder(locale: string | undefined, first: string, last: string): NameOrder {
|
|
60
|
+
const lang = language(locale);
|
|
61
|
+
if (lang === "hu") return "family-first";
|
|
62
|
+
if (lang === "zh" && (!first || CJK.test(first)) && (!last || CJK.test(last))) {
|
|
63
|
+
return "family-first-joined";
|
|
64
|
+
}
|
|
65
|
+
return "given-first";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clean(part: string | null | undefined): string {
|
|
69
|
+
return (part ?? "").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The full name in the reader's order: "Ada Example" (en, de, fr, it, es), "Example Ada"
|
|
74
|
+
* (hu), "李小龙" (zh, for a name in CJK characters — "Ada Lovelace" stays so). Trimmed;
|
|
75
|
+
* with one part empty, the other alone; with both empty, `""` — so a caller can fall
|
|
76
|
+
* back with `||` (`formatPersonName(p, l) || email`).
|
|
77
|
+
*
|
|
78
|
+
* `locale` is the READER's — the UI language, `useKitLocale()` in a component. Left out,
|
|
79
|
+
* the given-name-first order.
|
|
80
|
+
*/
|
|
81
|
+
export function formatPersonName(name: PersonName, locale?: string): string {
|
|
82
|
+
const first = clean(name.first);
|
|
83
|
+
const last = clean(name.last);
|
|
84
|
+
if (!last) return first;
|
|
85
|
+
if (!first) return last;
|
|
86
|
+
switch (nameOrder(locale, first, last)) {
|
|
87
|
+
case "family-first":
|
|
88
|
+
return `${last} ${first}`;
|
|
89
|
+
case "family-first-joined":
|
|
90
|
+
return `${last}${first}`;
|
|
91
|
+
default:
|
|
92
|
+
return `${first} ${last}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One segmenter for every avatar on the page, made on first use. `null`: the runtime
|
|
97
|
+
* has none, and code points have to do. */
|
|
98
|
+
let graphemes: Intl.Segmenter | null | undefined;
|
|
99
|
+
|
|
100
|
+
/** The first user-perceived character — "É" whether it was typed as one code point or
|
|
101
|
+
* as E + a combining accent, a whole emoji rather than half a surrogate pair. */
|
|
102
|
+
function firstGrapheme(text: string): string {
|
|
103
|
+
graphemes ??=
|
|
104
|
+
typeof Intl !== "undefined" && "Segmenter" in Intl
|
|
105
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
106
|
+
: null;
|
|
107
|
+
if (graphemes) {
|
|
108
|
+
const segment = graphemes.segment(text)[Symbol.iterator]().next();
|
|
109
|
+
if (!segment.done) return segment.value.segment;
|
|
110
|
+
}
|
|
111
|
+
return [...text][0] ?? "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Upper case under the reader's rules (Turkish "i" → "İ"), and plain upper case for a
|
|
115
|
+
* locale tag the runtime refuses rather than a thrown RangeError in a render. */
|
|
116
|
+
function upper(text: string, locale: string | undefined): string {
|
|
117
|
+
try {
|
|
118
|
+
return text.toLocaleUpperCase(locale);
|
|
119
|
+
} catch {
|
|
120
|
+
return text.toUpperCase();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The initials for an avatar, in the same order as {@link formatPersonName}:
|
|
126
|
+
*
|
|
127
|
+
* - "AE" for Ada Example in en, de, fr, it and es;
|
|
128
|
+
* - "EA" in hu, family name first;
|
|
129
|
+
* - **one character in zh for a name in CJK characters, the family name's first** —
|
|
130
|
+
* "李" for 李小龙. Two Chinese characters are not initials, they are most of a name.
|
|
131
|
+
* A Latin name in zh is "AE", as it is written.
|
|
132
|
+
*
|
|
133
|
+
* With only one part (a migrated user's whole old display name in `first`, §3.3), the
|
|
134
|
+
* initials are what {@link avatarInitials} has always made of a display name: the first
|
|
135
|
+
* letters of its first two words ("Ada Example" → "AE"), or the first two letters of a
|
|
136
|
+
* single word ("Ada" → "AD") — so nobody's avatar changes when their account migrates.
|
|
137
|
+
* In zh, a part in CJK characters gives its first character.
|
|
138
|
+
*
|
|
139
|
+
* `""` when both parts are blank; {@link UserAvatar} then falls back to the email.
|
|
140
|
+
*/
|
|
141
|
+
export function personInitials(name: PersonName, locale?: string): string {
|
|
142
|
+
const first = clean(name.first);
|
|
143
|
+
const last = clean(name.last);
|
|
144
|
+
if (!first && !last) return "";
|
|
145
|
+
const order = nameOrder(locale, first, last);
|
|
146
|
+
if (order === "family-first-joined") return upper(firstGrapheme(last || first), locale);
|
|
147
|
+
if (!first || !last) {
|
|
148
|
+
const words = (first || last).split(/\s+/);
|
|
149
|
+
const initials =
|
|
150
|
+
words.length >= 2
|
|
151
|
+
? firstGrapheme(words[0]!) + firstGrapheme(words[1]!)
|
|
152
|
+
: [...words[0]!].slice(0, 2).join("");
|
|
153
|
+
return upper(initials, locale);
|
|
154
|
+
}
|
|
155
|
+
const pair = order === "family-first" ? [last, first] : [first, last];
|
|
156
|
+
return upper(firstGrapheme(pair[0]!) + firstGrapheme(pair[1]!), locale);
|
|
157
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Building2 } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
7
|
+
import { Spinner } from "../components/ui";
|
|
8
|
+
import { OptionSwitcherMenu } from "./option-switcher-menu";
|
|
9
|
+
import { TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
|
|
10
|
+
|
|
11
|
+
/** Every string the switcher renders or speaks — the `companySwitcher` namespace. A
|
|
12
|
+
* different context (keksdose's budgets) renames them all. */
|
|
13
|
+
export interface CompanySwitcherLabels {
|
|
14
|
+
/** The trigger's and the menu's accessible name. */
|
|
15
|
+
switchCompany: string;
|
|
16
|
+
/** The small heading over the list. */
|
|
17
|
+
heading: string;
|
|
18
|
+
/** The trigger's tooltip (and description), given the current company's name. */
|
|
19
|
+
current: (name: string) => string;
|
|
20
|
+
/** Read after the current company's name in the list, where the eye sees a check. */
|
|
21
|
+
currentMark: string;
|
|
22
|
+
/** Said while a switch is running. */
|
|
23
|
+
switching: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_COMPANY_SWITCHER_LABELS: CompanySwitcherLabels = {
|
|
27
|
+
switchCompany: "Switch company",
|
|
28
|
+
heading: "Companies",
|
|
29
|
+
current: (name) => `Company: ${name}`,
|
|
30
|
+
currentMark: "(current)",
|
|
31
|
+
switching: "Switching company…",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** One company the user belongs to — kastlan's `/auth/me` `companies[]` (§5.3). */
|
|
35
|
+
export interface CompanySwitcherCompany<Id extends string | number = string | number> {
|
|
36
|
+
/** kastlan's ids are numbers; a string works the same. */
|
|
37
|
+
id: Id;
|
|
38
|
+
name: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CompanySwitcherProps<Id extends string | number> {
|
|
42
|
+
/** Every company the user belongs to. With fewer than two there is nothing to
|
|
43
|
+
* switch, and nothing renders. */
|
|
44
|
+
companies: readonly CompanySwitcherCompany<Id>[];
|
|
45
|
+
/** The company the session is in — `/auth/me`'s `current_company_id`. */
|
|
46
|
+
currentId: Id;
|
|
47
|
+
/**
|
|
48
|
+
* Another company was picked: `POST /auth/switch-company {company_id}`, store the
|
|
49
|
+
* fresh tokens, reload what is company-scoped. While the promise runs the switcher is
|
|
50
|
+
* disabled, so a second pick cannot race the first; a rejection frees it (the app
|
|
51
|
+
* says why). Picking the current company does nothing.
|
|
52
|
+
*/
|
|
53
|
+
onSwitch: (id: Id) => Promise<unknown> | void;
|
|
54
|
+
/** The trigger's glyph. Default a building; keksdose's budgets would pass a wallet. */
|
|
55
|
+
icon?: ReactNode;
|
|
56
|
+
labels?: Partial<CompanySwitcherLabels>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The company switcher in the top bar (docs/auth-harmonization.md §5.3, §8): a kastlan
|
|
61
|
+
* user may belong to several companies, and the access token carries the current one,
|
|
62
|
+
* so switching is a request for new tokens — `onSwitch`, the app's. Built on
|
|
63
|
+
* {@link OptionSwitcherMenu}, like `RoleSwitcher`: the icon trigger, the current company
|
|
64
|
+
* in its tooltip, the list with a check on the current one.
|
|
65
|
+
*
|
|
66
|
+
* **Only for more than one company** — a user in one company has nothing to switch, and
|
|
67
|
+
* the top bar stays as it was. **Disabled while a switch runs**: the trigger is swapped
|
|
68
|
+
* for a disabled one with a spinner (`aria-busy`), and "Switching company…" is said, so
|
|
69
|
+
* a second pick cannot race the first. The kit never sends the request.
|
|
70
|
+
*
|
|
71
|
+
* A generic context switcher: the labels rename it ("Switch budget", "Budgets"), `icon`
|
|
72
|
+
* replaces the building, and `id` may be a number (kastlan's) or a string.
|
|
73
|
+
*/
|
|
74
|
+
export function CompanySwitcher<Id extends string | number>({
|
|
75
|
+
companies,
|
|
76
|
+
currentId,
|
|
77
|
+
onSwitch,
|
|
78
|
+
icon,
|
|
79
|
+
labels: labelsProp,
|
|
80
|
+
}: CompanySwitcherProps<Id>) {
|
|
81
|
+
const labels = useKitLabels("companySwitcher", DEFAULT_COMPANY_SWITCHER_LABELS, labelsProp);
|
|
82
|
+
const [pending, setPending] = useState(false);
|
|
83
|
+
const { announce, regionProps } = useAnnounce();
|
|
84
|
+
if (companies.length < 2) return null;
|
|
85
|
+
|
|
86
|
+
const current = companies.find((c) => c.id === currentId);
|
|
87
|
+
const select = (value: string) => {
|
|
88
|
+
if (pending) return;
|
|
89
|
+
const picked = companies.find((c) => String(c.id) === value);
|
|
90
|
+
if (!picked || picked.id === currentId) return;
|
|
91
|
+
const result = onSwitch(picked.id);
|
|
92
|
+
if (typeof (result as PromiseLike<unknown> | undefined)?.then !== "function") return;
|
|
93
|
+
setPending(true);
|
|
94
|
+
announce(labels.switching);
|
|
95
|
+
(result as PromiseLike<unknown>).then(
|
|
96
|
+
() => setPending(false),
|
|
97
|
+
() => setPending(false),
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{pending ? (
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
disabled
|
|
107
|
+
aria-busy="true"
|
|
108
|
+
aria-label={labels.switchCompany}
|
|
109
|
+
className={cn(TOPBAR_TRIGGER_CLASS, "cursor-wait opacity-60 hover:bg-transparent")}
|
|
110
|
+
>
|
|
111
|
+
<Spinner label={null} />
|
|
112
|
+
</button>
|
|
113
|
+
) : (
|
|
114
|
+
<OptionSwitcherMenu<string>
|
|
115
|
+
ariaLabel={labels.switchCompany}
|
|
116
|
+
title={current ? labels.current(current.name) : undefined}
|
|
117
|
+
heading={labels.heading}
|
|
118
|
+
icon={icon ?? <Building2 className="size-5" />}
|
|
119
|
+
options={companies.map((c) => ({
|
|
120
|
+
value: String(c.id),
|
|
121
|
+
label:
|
|
122
|
+
c.id === currentId ? (
|
|
123
|
+
// `relative`: the sr-only mark needs a local containing block.
|
|
124
|
+
<span className="relative">
|
|
125
|
+
{c.name}
|
|
126
|
+
<span className="sr-only"> {labels.currentMark}</span>
|
|
127
|
+
</span>
|
|
128
|
+
) : (
|
|
129
|
+
c.name
|
|
130
|
+
),
|
|
131
|
+
}))}
|
|
132
|
+
value={String(currentId)}
|
|
133
|
+
onSelect={select}
|
|
134
|
+
/>
|
|
135
|
+
)}
|
|
136
|
+
{/* Outside both triggers, so it survives the swap and is read when it changes. */}
|
|
137
|
+
<span {...regionProps} />
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|