- {!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 (
-