diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 3490125..c36227c 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,31 +1,61 @@ +import { headers } from "next/headers"; import { AppSidebar } from "@/components/app-sidebar"; import { HeaderBreadcrumb } from "@/components/header-breadcrumb"; +import type { OrgOption } from "@/components/org-switcher"; +import { + RefreshPendingOverlay, + RefreshPendingProvider, +} from "@/components/refresh-pending"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; +import { auth } from "@/lib/auth"; +import { getSession } from "@/lib/session"; -export default function DashboardLayout({ +export default async function DashboardLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + // 側邊欄的組織切換器需要的資料在這裡一次撈好。session 本來就帶著 active org, + // 組織清單也只是一次查詢 —— 交給 client hook 現抓的話,第一次繪製只會是一個 + // 空白又不能按的下拉選單。這裡不擋權限(各頁自己 requireOrg()),撈不到就給空的。 + const reqHeaders = await headers(); + const session = await getSession(); + const organizations = session + ? ((await auth.api.listOrganizations({ headers: reqHeaders })) ?? []) + : []; + const initialOrganizations: OrgOption[] = organizations.map((o) => ({ + id: o.id, + name: o.name, + slug: o.slug, + })); + const initialActiveOrgId = session?.session.activeOrganizationId ?? null; + return ( - - - -
- - - -
-
{children}
-
-
+ + + + +
+ + + +
+ + {children} + +
+
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index c41ba31..d16b8db 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -216,4 +216,14 @@ html { @apply font-sans; } -} \ No newline at end of file +} + +/* 組織切換等待 server 重新渲染時,主內容區頂端那條不定量進度條。 */ +@keyframes refresh-bar { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 2a58446..40dc6f1 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -6,6 +6,10 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Building2, MailCheck } from "lucide-react"; import { authClient } from "@/lib/auth-client"; +import { + PendingInvitations, + useUserInvitations, +} from "@/components/pending-invitations"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -25,27 +29,16 @@ function slugify(name: string) { .replaceAll(/^-|-$/g, ""); } -type Invite = { - id: string; - organizationId: string; - organizationName: string; - role: string; - expiresAt: string | Date; -}; - export default function OnboardingPage() { const t = useTranslations("auth.onboarding"); + const ti = useTranslations("auth.invites"); const router = useRouter(); const [creating, setCreating] = useState(false); - const [invites, setInvites] = useState([]); - const [loadingInvites, setLoadingInvites] = useState(true); - const [acceptingId, setAcceptingId] = useState(null); + // 邀請的取得與接受/婉拒都在共用元件裡(user menu 的邀請對話框用的是同一份)。 + const invitations = useUserInvitations(); // Until we know whether the user already belongs to an org, render nothing so // we don't flash the create-org UI at members who shouldn't see it. const [checkingMembership, setCheckingMembership] = useState(true); - // 掛載時取一次「現在」的快照:在 render 裡直接讀 Date.now() 是不純的,而邀請 - // 的到期是以天為單位,不需要跟著時間跳動。 - const [now] = useState(() => Date.now()); useEffect(() => { (async () => { @@ -57,41 +50,16 @@ export default function OnboardingPage() { return; } setCheckingMembership(false); - - const { data, error } = await authClient.organization.listUserInvitations(); - if (!error && data) setInvites(data as unknown as Invite[]); - setLoadingInvites(false); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // listUserInvitations 只 filter status === "pending",不管 expiresAt,而過期的邀請 - // 在 DB 裡 status 仍然是 pending。所以後端回來的清單裡可能夾著一按就必定失敗 - // (INVITATION_NOT_FOUND,訊息看起來像「找不到邀請」)的邀請,得在前端自己分開。 - const expired = (inv: Invite) => new Date(inv.expiresAt).getTime() < now; - const acceptableInvites = invites.filter((i) => !expired(i)); - const expiredInvites = invites.filter(expired); - async function enter(organizationId: string) { await authClient.organization.setActive({ organizationId }); router.push("/dashboard"); router.refresh(); } - async function onAccept(inv: Invite) { - setAcceptingId(inv.id); - const { error } = await authClient.organization.acceptInvitation({ - invitationId: inv.id, - }); - if (error) { - setAcceptingId(null); - toast.error(error.message || t("invites.toast.acceptFailed")); - return; - } - toast.success(t("invites.toast.joined", { name: inv.organizationName })); - await enter(inv.organizationId); - } - async function onCreate(e: React.FormEvent) { e.preventDefault(); const form = new FormData(e.currentTarget); @@ -117,58 +85,22 @@ export default function OnboardingPage() { ); } + const hasInvites = invitations.invites.length > 0; + return (
- {!loadingInvites && invites.length > 0 && ( + {!invitations.loading && hasInvites && ( - {t("invites.title")} + {ti("title")} - {t("invites.description")} + {ti("description")} - - {acceptableInvites.map((inv) => ( -
-
-
{inv.organizationName}
-
- {t("invites.role", { role: inv.role })} -
-
- -
- ))} - {/* 過期的只顯示、不給按鈕 —— 接受一定會失敗,給按鈕只是騙人。 */} - {expiredInvites.map((inv) => ( -
-
-
- {inv.organizationName} -
-
- {t("invites.expiredHint")} -
-
- - {t("invites.expired")} - -
- ))} + +
)} @@ -179,7 +111,7 @@ export default function OnboardingPage() { {t("create.title")} - {invites.length > 0 ? ( + {hasInvites ? ( {t("create.descriptionWithInvites")} ) : null} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 49e0e3c..40a96b4 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -38,7 +38,7 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; -import { OrgSwitcher } from "@/components/org-switcher"; +import { OrgSwitcher, type OrgOption } from "@/components/org-switcher"; import { UserMenu } from "@/components/user-menu"; import { LocaleSwitcher } from "@/components/locale-switcher"; import { Logo } from "@/components/logo"; @@ -113,7 +113,17 @@ const groups = [ { label: "org", items: org }, ] as const; -export function AppSidebar() { +/** + * initialOrganizations / initialActiveOrgId 由 dashboard 的 server layout 撈好傳進來, + * 讓組織切換器第一次繪製就有東西可顯示(純 client fetch 的話會先閃一格空白的下拉)。 + */ +export function AppSidebar({ + initialOrganizations, + initialActiveOrgId, +}: Readonly<{ + initialOrganizations: OrgOption[]; + initialActiveOrgId: string | null; +}>) { const t = useTranslations("common"); const pathname = usePathname(); @@ -129,7 +139,10 @@ export function AppSidebar() { - + diff --git a/src/components/org-switcher.tsx b/src/components/org-switcher.tsx index 301f221..e658933 100644 --- a/src/components/org-switcher.tsx +++ b/src/components/org-switcher.tsx @@ -1,17 +1,13 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { Building2 } from "lucide-react"; +import { Building2, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; -import { - authClient, - useActiveOrganization, - useListOrganizations, -} from "@/lib/auth-client"; - -const LAST_ORG_KEY = "lastOrgId"; +import { authClient, useListOrganizations } from "@/lib/auth-client"; +import { useRefreshPending } from "@/components/refresh-pending"; +import { Skeleton } from "@/components/ui/skeleton"; import { Select, SelectContent, @@ -20,53 +16,73 @@ import { SelectValue, } from "@/components/ui/select"; -export function OrgSwitcher() { +/** 由 server layout 一次撈好丟進來的組織,形狀要能序列化。 */ +export type OrgOption = { + id: string; + name: string; + slug: string; +}; + +/** + * 側邊欄的組織切換器。 + * + * 初始資料一律來自 server layout(session 本來就知道 active org,不必等 client fetch), + * client hook 只負責在首次繪製之後把清單保持新鮮 —— 這樣就不會再出現「一開始是空的、 + * 而且還 disabled」那一瞬間。選擇後先樂觀更新本地的 selectedId,畫面立刻換成新組織, + * 真正的 setActive + router.refresh() 在背景跑,期間由 RefreshPendingOverlay 給回饋。 + */ +export function OrgSwitcher({ + initialOrganizations, + initialActiveOrgId, +}: Readonly<{ + initialOrganizations: OrgOption[]; + initialActiveOrgId: string | null; +}>) { const t = useTranslations("common"); const router = useRouter(); const { data: organizations } = useListOrganizations(); - const { data: activeOrg } = useActiveOrganization(); - const [switching, setSwitching] = useState(false); - // Guard so the localStorage-driven auto-switch only runs once per mount. - const restoredRef = useRef(false); + const [selectedId, setSelectedId] = useState(initialActiveOrgId ?? ""); + // transition 開在 RefreshPendingProvider,這樣 pending 會一路撐到 router.refresh() + // 帶回新組織的畫面為止,主內容區才有辦法在整段等待期間給回饋。 + const { pending, startPending } = useRefreshPending(); - // On first load, if the user previously picked a different org than the - // session's active one, restore that choice from localStorage. - useEffect(() => { - if (restoredRef.current) return; - if (!organizations || organizations.length === 0 || !activeOrg) return; - restoredRef.current = true; - const stored = localStorage.getItem(LAST_ORG_KEY); - if (stored && stored !== activeOrg.id && organizations.some((o) => o.id === stored)) { - void onChange(stored); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [organizations, activeOrg]); + // hook 還沒回來之前用 server 給的清單;回來之後才換成最新的(有人新建組織就會更新)。 + const orgs: OrgOption[] = organizations ?? initialOrganizations; + + function onChange(organizationId: string) { + if (!organizationId || organizationId === selectedId) return; + const previousId = selectedId; + // 先樂觀切過去:下拉選單立刻顯示新組織,不用等 setActive 回來。 + setSelectedId(organizationId); + startPending(async () => { + const { error } = await authClient.organization.setActive({ organizationId }); + if (error) { + setSelectedId(previousId); + toast.error(error.message || t("orgSwitcher.toast.switchFailed")); + return; + } + // force-dynamic 的頁面要重新由 server 產出才會換成新組織的資料。 + router.refresh(); + }); + } - async function onChange(organizationId: string | null) { - if (!organizationId || organizationId === activeOrg?.id) return; - setSwitching(true); - const { error } = await authClient.organization.setActive({ organizationId }); - setSwitching(false); - if (error) { - toast.error(error.message || t("orgSwitcher.toast.switchFailed")); - return; - } - localStorage.setItem(LAST_ORG_KEY, organizationId); - router.refresh(); + // 真的什麼都還不知道時才給骨架;只要拿得到組織名稱就不該顯示空的下拉選單。 + if (orgs.length === 0 && !selectedId) { + return ; } return ( - - + {pending ? ( + + ) : ( + + )} - {(organizations ?? []).map((o) => ( + {orgs.map((o) => ( {o.name} diff --git a/src/components/pending-invitations.tsx b/src/components/pending-invitations.tsx new file mode 100644 index 0000000..c4ac31c --- /dev/null +++ b/src/components/pending-invitations.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatDate } from "@/lib/format"; + +export type UserInvite = { + id: string; + organizationId: string; + organizationName: string; + role: string; + expiresAt: string | Date; +}; + +export type UserInvitations = { + invites: UserInvite[]; + acceptable: UserInvite[]; + expired: UserInvite[]; + loading: boolean; + refresh: () => Promise; +}; + +/** + * 這位使用者收到、還沒處理的組織邀請。 + * + * listUserInvitations 只 filter status === "pending",不管 expiresAt,而過期的邀請在 DB 裡 + * status 仍然是 pending。所以後端回來的清單裡可能夾著一按就必定失敗(INVITATION_NOT_FOUND, + * 訊息看起來像「找不到邀請」)的邀請,得在前端自己分開。 + * + * 只在 mount 時取一次,另外把 refresh() 交出去讓呼叫端自己決定何時重取(例如選單打開時), + * 不做持續輪詢。 + */ +export function useUserInvitations(): UserInvitations { + const [invites, setInvites] = useState([]); + const [loading, setLoading] = useState(true); + // 取清單的當下記一次「現在」:在 render 裡直接讀 Date.now() 是不純的,而邀請的到期 + // 是以天為單位,不需要跟著時間跳動。 + const [now, setNow] = useState(() => Date.now()); + + const refresh = useCallback(async () => { + const { data, error } = await authClient.organization.listUserInvitations(); + setInvites(!error && data ? (data as unknown as UserInvite[]) : []); + setNow(Date.now()); + setLoading(false); + }, []); + + useEffect(() => { + (async () => { + await refresh(); + })(); + }, [refresh]); + + return useMemo(() => { + const isExpired = (inv: UserInvite) => new Date(inv.expiresAt).getTime() < now; + return { + invites, + acceptable: invites.filter((i) => !isExpired(i)), + expired: invites.filter(isExpired), + loading, + refresh, + }; + }, [invites, loading, now, refresh]); +} + +/** + * 邀請清單本體(onboarding 與 user menu 的邀請對話框共用)。 + * 資料由呼叫端用 useUserInvitations() 取好傳進來,避免同一頁重複請求。 + */ +export function PendingInvitations({ + invitations, + onAccepted, +}: Readonly<{ + invitations: UserInvitations; + /** 接受成功、準備跳轉前呼叫(例如關掉對話框)。 */ + onAccepted?: () => void; +}>) { + const t = useTranslations("auth.invites"); + const router = useRouter(); + const { acceptable, expired, loading, refresh } = invitations; + const [busyId, setBusyId] = useState(null); + + async function onAccept(inv: UserInvite) { + setBusyId(inv.id); + const { error } = await authClient.organization.acceptInvitation({ + invitationId: inv.id, + }); + if (error) { + setBusyId(null); + toast.error(error.message || t("toast.acceptFailed")); + return; + } + toast.success(t("toast.joined", { name: inv.organizationName })); + onAccepted?.(); + await authClient.organization.setActive({ organizationId: inv.organizationId }); + router.push("/dashboard"); + router.refresh(); + } + + async function onDecline(inv: UserInvite) { + setBusyId(inv.id); + const { error } = await authClient.organization.rejectInvitation({ + invitationId: inv.id, + }); + setBusyId(null); + if (error) { + toast.error(error.message || t("toast.declineFailed")); + return; + } + toast.success(t("toast.declined", { name: inv.organizationName })); + await refresh(); + } + + if (loading) { + return ( +
+ + +
+ ); + } + + if (acceptable.length === 0 && expired.length === 0) { + return

{t("empty")}

; + } + + return ( +
+ {acceptable.map((inv) => ( +
+
+
{inv.organizationName}
+
+ {t("role", { role: inv.role })} ·{" "} + {t("expiresAt", { date: formatDate(inv.expiresAt) })} +
+
+
+ + +
+
+ ))} + {/* 過期的只顯示、不給按鈕 —— 接受一定會失敗,給按鈕只是騙人。 */} + {expired.map((inv) => ( +
+
+
+ {inv.organizationName} +
+
{t("expiredHint")}
+
+ {t("expired")} +
+ ))} +
+ ); +} diff --git a/src/components/refresh-pending.tsx b/src/components/refresh-pending.tsx new file mode 100644 index 0000000..3ae551a --- /dev/null +++ b/src/components/refresh-pending.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { createContext, useContext, useMemo, useTransition } from "react"; +import { cn } from "@/lib/utils"; + +type RefreshPendingValue = { + /** 有工作正在跑,而且它引發的 router.refresh() 還沒回來。 */ + pending: boolean; + /** 把一段「做完會 router.refresh()」的工作交給共用的 transition 跑。 */ + startPending: (task: () => Promise | void) => void; +}; + +// 預設值是直接執行、永不 pending,所以就算元件被放在 Provider 外面也不會爆。 +const RefreshPendingContext = createContext({ + pending: false, + startPending: (task) => { + Promise.resolve(task()).catch(() => {}); + }, +}); + +/** + * 「畫面正在等 server 重新渲染」的共享狀態。 + * + * 切換組織時 router.refresh() 會把每一頁 force-dynamic 的 server component 重跑一次, + * 期間畫面上的資料仍是舊組織的,但看起來完全正常 —— 使用者只會覺得「按了沒反應」。 + * + * transition 開在這裡(而不是各自在呼叫端)是關鍵:router.refresh() 要在 transition 裡 + * 呼叫,isPending 才會一路撐到 server 回來為止。呼叫端自己開 transition 的話,只能在 + * 送出 refresh() 的當下就把旗標關掉 —— 那正好是等待開始的時間點。 + */ +export function RefreshPendingProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const [pending, startTransition] = useTransition(); + const value = useMemo( + () => ({ pending, startPending: (task) => startTransition(task) }), + [pending], + ); + return ( + + {children} + + ); +} + +export function useRefreshPending() { + return useContext(RefreshPendingContext); +} + +/** + * 主內容區的外框:pending 時壓暗並擋掉點擊,同時在最上緣顯示一條不定量進度條。 + * 直接算成
,這樣 layout 原本的 flex 版面不會多包一層而跑掉。 + * + * 那條進度條純粹是裝飾(aria-hidden):它沒有進度數值可報,硬掛 progressbar role 只會 + * 讓螢幕閱讀器唸出一個沒有 aria-valuenow 的空殼。真正要傳達的「內容正在更新」由主內容 + * 區自己的 aria-busy 負責。 + */ +export function RefreshPendingOverlay({ + className, + children, +}: Readonly<{ className?: string; children: React.ReactNode }>) { + const { pending } = useRefreshPending(); + return ( + <> + {pending ? ( +
+
+
+ ) : null} +
+ {children} +
+ + ); +} diff --git a/src/components/user-menu.tsx b/src/components/user-menu.tsx index 3c8d90b..4fdee50 100644 --- a/src/components/user-menu.tsx +++ b/src/components/user-menu.tsx @@ -1,20 +1,52 @@ "use client"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { LogOut } from "lucide-react"; +import { ChevronsUpDown, LogOut, MailCheck } from "lucide-react"; import { useTranslations } from "next-intl"; -import { signOut, useSession } from "@/lib/auth-client"; +import { signOut, useActiveOrganization, useSession } from "@/lib/auth-client"; +import { + PendingInvitations, + useUserInvitations, +} from "@/components/pending-invitations"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +/** + * 側邊欄底部的使用者選單。 + * + * 之前這顆按鈕按下去就直接登出 —— 沒有選單、也沒有確認,手滑一次就掉出去。改成 + * DropdownMenu 之後登出變成要先開選單再選,同時多了一個到得了的邀請入口:待處理的 + * 邀請本來只有 /onboarding 看得到,而那頁只有「還沒有任何組織」的人進得去。 + */ export function UserMenu() { const t = useTranslations("common"); const router = useRouter(); const { data: session } = useSession(); + const { data: activeOrg } = useActiveOrganization(); + const invitations = useUserInvitations(); + const [invitesOpen, setInvitesOpen] = useState(false); const user = session?.user; + const pendingCount = invitations.acceptable.length; async function onSignOut() { await signOut(); @@ -25,18 +57,81 @@ export function UserMenu() { return ( - -
- {user?.name?.[0]?.toUpperCase() ?? "?"} -
-
- {user?.name ?? t("userMenu.notSignedIn")} - - {user?.email ?? ""} - -
- -
+ { + // 選單打開時順手重取一次,不做持續輪詢。 + if (open) void invitations.refresh(); + }} + > + + +
+ {user?.name?.[0]?.toUpperCase() ?? "?"} + {pendingCount > 0 ? ( + + ) : null} +
+
+ + {user?.name ?? t("userMenu.notSignedIn")} + + + {user?.email ?? ""} + +
+ +
+
+ + + +
+ + {user?.name ?? t("userMenu.notSignedIn")} + + + {user?.email ?? ""} + + {activeOrg?.name ? ( + + {t("userMenu.currentOrg", { name: activeOrg.name })} + + ) : null} +
+
+ + setInvitesOpen(true)}> + + {t("userMenu.invitations")} + {pendingCount > 0 ? ( + {pendingCount} + ) : null} + + + + + {t("userMenu.signOut")} + +
+
+ + + + + {t("userMenu.invitationsTitle")} + + {t("userMenu.invitationsDescription")} + + + setInvitesOpen(false)} + /> + +
); diff --git a/src/i18n/messages/auth.ts b/src/i18n/messages/auth.ts index c43687c..5f5f16d 100644 --- a/src/i18n/messages/auth.ts +++ b/src/i18n/messages/auth.ts @@ -38,21 +38,28 @@ const auth = { }, }, }, + // 邀請清單是 onboarding 與側邊欄 user menu 的邀請對話框共用的(元件也是同一個), + // 所以字串放在 auth.invites 這層,而不是埋在 auth.onboarding 底下。 + invites: { + title: { "zh-TW": "你收到的邀請", en: "Your invitations" }, + description: { "zh-TW": "接受邀請以加入既有組織", en: "Accept an invitation to join an existing organization" }, + role: { "zh-TW": "身分:{role}", en: "Role: {role}" }, + expiresAt: { "zh-TW": "到期:{date}", en: "Expires {date}" }, + accept: { "zh-TW": "接受", en: "Accept" }, + accepting: { "zh-TW": "加入中…", en: "Joining…" }, + decline: { "zh-TW": "婉拒", en: "Decline" }, + expired: { "zh-TW": "已過期", en: "Expired" }, + expiredHint: { "zh-TW": "這個邀請已過期,請聯絡管理員重新邀請", en: "This invitation has expired — ask an admin to invite you again" }, + empty: { "zh-TW": "目前沒有待處理的邀請", en: "No pending invitations" }, + toast: { + acceptFailed: { "zh-TW": "接受邀請失敗", en: "Failed to accept invitation" }, + joined: { "zh-TW": "已加入 {name}", en: "Joined {name}" }, + declineFailed: { "zh-TW": "婉拒邀請失敗", en: "Failed to decline invitation" }, + declined: { "zh-TW": "已婉拒 {name} 的邀請", en: "Declined the invitation from {name}" }, + }, + }, onboarding: { loading: { "zh-TW": "載入中…", en: "Loading…" }, - invites: { - title: { "zh-TW": "你收到的邀請", en: "Your invitations" }, - description: { "zh-TW": "接受邀請以加入既有組織", en: "Accept an invitation to join an existing organization" }, - role: { "zh-TW": "身分:{role}", en: "Role: {role}" }, - accept: { "zh-TW": "接受", en: "Accept" }, - accepting: { "zh-TW": "加入中…", en: "Joining…" }, - expired: { "zh-TW": "已過期", en: "Expired" }, - expiredHint: { "zh-TW": "這個邀請已過期,請聯絡管理員重新邀請", en: "This invitation has expired — ask an admin to invite you again" }, - toast: { - acceptFailed: { "zh-TW": "接受邀請失敗", en: "Failed to accept invitation" }, - joined: { "zh-TW": "已加入 {name}", en: "Joined {name}" }, - }, - }, create: { title: { "zh-TW": "建立組織", en: "Create an organization" }, descriptionWithInvites: { "zh-TW": "或建立一個新的組織", en: "Or create a new organization" }, diff --git a/src/i18n/messages/common.ts b/src/i18n/messages/common.ts index a19d1a9..bf960be 100644 --- a/src/i18n/messages/common.ts +++ b/src/i18n/messages/common.ts @@ -105,6 +105,13 @@ const common = { userMenu: { signOut: { "zh-TW": "登出", en: "Sign out" }, notSignedIn: { "zh-TW": "未登入", en: "Not signed in" }, + currentOrg: { "zh-TW": "組織:{name}", en: "Organization: {name}" }, + invitations: { "zh-TW": "邀請", en: "Invitations" }, + invitationsTitle: { "zh-TW": "你收到的邀請", en: "Your invitations" }, + invitationsDescription: { + "zh-TW": "接受後會直接切換到該組織", + en: "Accepting switches you to that organization", + }, }, } satisfies Dictionary;