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