History

Show cap-reached message with Upgrade link in invite dialog

Replace the generic "Failed to send invitation" toast with cause-aware rendering: store the raw Effect Cause from the mutation Exit, then pattern-match in render. When the cause contains a Forbidden, show "You've reached your member limit. Upgrade to Team to invite more." with an inline Upgrade button to /billing/plans; fall back to the generic message otherwise. No parallel error type — the tagged error from the API drives the UI directly.

Rhys Sullivan committed 6 months ago

Commit dbc49df1 parent 27036831 file · +36 −13Browse files

Unified · Split

apps/cloud/src/routes/org.tsx+36−13
@@ -1,5 +1,6 @@
11 import { useReducer, useState } from "react";
2−import { Exit } from "effect";
2+import { Cause, Exit, Result } from "effect";
3+import { Forbidden } from "../org/api";
34 import { createFileRoute, Link } from "@tanstack/react-router";
45 import { useAtomValue, useAtomSet } from "@effect/atom-react";
56 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -62,21 +63,21 @@ type InviteState = { · 56 unmodified lines
6263 email: string;
6364 roleSlug: string;
6465 status: "idle" | "sending" | "error";
65− error: string | null;
66+ failure: Cause.Cause<unknown> | null;
6667 };
6768
6869 const initialInviteState: InviteState = {
6970 email: "",
7071 roleSlug: "member",
7172 status: "idle",
72− error: null,
73+ failure: null,
7374 };
7475
7576 type InviteAction =
7677 | { type: "setEmail"; email: string }
7778 | { type: "setRole"; roleSlug: string }
7879 | { type: "send" }
79− | { type: "error"; message: string }
80+ | { type: "error"; cause: Cause.Cause<unknown> }
8081 | { type: "reset" };
8182
8283 function inviteReducer(state: InviteState, action: InviteAction): InviteState {
@@ -86,9 +87,9 @@ function inviteReducer(state: InviteState, action: InviteAction): InviteState { · 3 unmodified lines
8687 case "setRole":
8788 return { ...state, roleSlug: action.roleSlug };
8889 case "send":
89− return { ...state, status: "sending", error: null };
90+ return { ...state, status: "sending", failure: null };
9091 case "error":
91− return { ...state, status: "error", error: action.message };
92+ return { ...state, status: "error", failure: action.cause };
9293 case "reset":
9394 return initialInviteState;
9495 }
@@ -606,6 +607,32 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () · 511 unmodified lines
606607 );
607608 }
608609
610+function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) {
611+ const failure = Cause.findError(cause);
612+ const error = Result.isSuccess(failure) ? failure.success : null;
613+
614+ if (error instanceof Forbidden) {
615+ return (
616+ <div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
617+ <p className="text-sm text-destructive">
618+ You've reached your member limit. Upgrade to Team to invite more.
619+ </p>
620+ <Link to="/billing/plans">
621+ <Button size="sm" variant="outline">
622+ Upgrade
623+ </Button>
624+ </Link>
625+ </div>
626+ );
627+ }
628+
629+ return (
630+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
631+ <p className="text-sm text-destructive">Failed to send invitation. Please try again.</p>
632+ </div>
633+ );
634+}
635+
609636 function InviteDialog(props: {
610637 open: boolean;
611638 onOpenChange: (v: boolean) => void;
@@ -630,9 +657,9 @@ function InviteDialog(props: { · 18 unmodified lines
630657 toast.success(`Invitation sent to ${state.email.trim()}`);
631658 dispatch({ type: "reset" });
632659 props.onOpenChange(false);
633− } else {
634− dispatch({ type: "error", message: "Failed to send invitation" });
660+ return;
635661 }
662+ dispatch({ type: "error", cause: exit.cause });
636663 };
637664
638665 return (
@@ -700,11 +727,7 @@ function InviteDialog(props: { · 61 unmodified lines
700727 </div>
701728 )}
702729
703− {state.status === "error" && state.error && (
704− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
705− <p className="text-sm text-destructive">{state.error}</p>
706− </div>
707− )}
730+ {state.status === "error" && state.failure && <InviteErrorAlert cause={state.failure} />}
708731 </div>
709732
710733 <DialogFooter>