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.
Unified · Split
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { useReducer, useState } from "react"; |
| 2 | −import { Exit } from "effect"; | |
| 2 | +import { Cause, Exit, Result } from "effect"; | |
| 3 | +import { Forbidden } from "../org/api"; | |
| 3 | 4 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 4 | 5 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 5 | 6 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -62,21 +63,21 @@ type InviteState = { · 56 unmodified lines | ||
| 62 | 63 | email: string; |
| 63 | 64 | roleSlug: string; |
| 64 | 65 | status: "idle" | "sending" | "error"; |
| 65 | − error: string | null; | |
| 66 | + failure: Cause.Cause<unknown> | null; | |
| 66 | 67 | }; |
| 67 | 68 | |
| 68 | 69 | const initialInviteState: InviteState = { |
| 69 | 70 | email: "", |
| 70 | 71 | roleSlug: "member", |
| 71 | 72 | status: "idle", |
| 72 | − error: null, | |
| 73 | + failure: null, | |
| 73 | 74 | }; |
| 74 | 75 | |
| 75 | 76 | type InviteAction = |
| 76 | 77 | | { type: "setEmail"; email: string } |
| 77 | 78 | | { type: "setRole"; roleSlug: string } |
| 78 | 79 | | { type: "send" } |
| 79 | − | { type: "error"; message: string } | |
| 80 | + | { type: "error"; cause: Cause.Cause<unknown> } | |
| 80 | 81 | | { type: "reset" }; |
| 81 | 82 | |
| 82 | 83 | function inviteReducer(state: InviteState, action: InviteAction): InviteState { |
| @@ -86,9 +87,9 @@ function inviteReducer(state: InviteState, action: InviteAction): InviteState { · 3 unmodified lines | ||
| 86 | 87 | case "setRole": |
| 87 | 88 | return { ...state, roleSlug: action.roleSlug }; |
| 88 | 89 | case "send": |
| 89 | − return { ...state, status: "sending", error: null }; | |
| 90 | + return { ...state, status: "sending", failure: null }; | |
| 90 | 91 | case "error": |
| 91 | − return { ...state, status: "error", error: action.message }; | |
| 92 | + return { ...state, status: "error", failure: action.cause }; | |
| 92 | 93 | case "reset": |
| 93 | 94 | return initialInviteState; |
| 94 | 95 | } |
| @@ -606,6 +607,32 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () · 511 unmodified lines | ||
| 606 | 607 | ); |
| 607 | 608 | } |
| 608 | 609 | |
| 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 | + | |
| 609 | 636 | function InviteDialog(props: { |
| 610 | 637 | open: boolean; |
| 611 | 638 | onOpenChange: (v: boolean) => void; |
| @@ -630,9 +657,9 @@ function InviteDialog(props: { · 18 unmodified lines | ||
| 630 | 657 | toast.success(`Invitation sent to ${state.email.trim()}`); |
| 631 | 658 | dispatch({ type: "reset" }); |
| 632 | 659 | props.onOpenChange(false); |
| 633 | − } else { | |
| 634 | − dispatch({ type: "error", message: "Failed to send invitation" }); | |
| 660 | + return; | |
| 635 | 661 | } |
| 662 | + dispatch({ type: "error", cause: exit.cause }); | |
| 636 | 663 | }; |
| 637 | 664 | |
| 638 | 665 | return ( |
| @@ -700,11 +727,7 @@ function InviteDialog(props: { · 61 unmodified lines | ||
| 700 | 727 | </div> |
| 701 | 728 | )} |
| 702 | 729 | |
| 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} />} | |
| 708 | 731 | </div> |
| 709 | 732 | |
| 710 | 733 | <DialogFooter> |