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 @@
1import { useReducer, useState } from "react";1import { useReducer, useState } from "react";
2import { Exit } from "effect";2import { Cause, Exit, Result } from "effect";
3import { Forbidden } from "../org/api";
3import { createFileRoute, Link } from "@tanstack/react-router";4import { createFileRoute, Link } from "@tanstack/react-router";
4import { useAtomValue, useAtomSet } from "@effect/atom-react";5import { useAtomValue, useAtomSet } from "@effect/atom-react";
5import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";6import * 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
68const initialInviteState: InviteState = {69const 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
75type InviteAction =76type 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
82function inviteReducer(state: InviteState, action: InviteAction): InviteState {83function 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
610function 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
609function InviteDialog(props: {636function 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>