Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface SubscriptionSubmitButtonProps {
packageUpdates: PackageUpdateInput[];
/** Desired end-state for the checkout flow. */
checkoutProducts: ProductCheckoutInput[];
/** True when a Custom Amount has an empty/invalid quantity (update flow only). */
/** True when a Custom Amount has an empty/invalid quantity. */
hasInvalidCustom: boolean;
/**
* The AI spending cap to store, when the user changed it: a USD figure, or
Expand All @@ -41,7 +41,8 @@ interface SubscriptionSubmitButtonProps {
*
* The ACTION still splits on the subscription state:
* - no active paid subscription → `createCheckoutSession`, which redirects to
* Stripe. No diff gating: there is nothing to compare against.
* Stripe. No diff gating: there is nothing to compare against — but disabled
* while there is nothing to buy, and validated on click.
* - active paid subscription → `updateSubscription`, a mutation that applies the
* plan change in place and does NOT redirect to a payment page (an upgrade may
* raise an invoice afterwards). Disabled when the selection equals the current
Expand Down Expand Up @@ -87,30 +88,32 @@ export function SubscriptionSubmitButton({
};

if (needsCheckout) {
const handleCheckout = () => {
// Checkout has no diff to gate on, but an out-of-range quantity is still
// one: it would be sent as a plan nobody can be billed for.
if (hasInvalidCustom) {
rejectInvalidAmount();
return;
}
if (!checkoutProducts.length) return;
// Opened from the click itself, and carried through both mutations:
// Stripe's URL only exists once the second answers, and a tab opened
// then has lost the user gesture that lets it through (see
// `openDeferredTab`). It is closed again if either step fails.
const tab = openDeferredTab();
withAiSpendCap(
() => createCheckout.mutate({ products: checkoutProducts }, { target: tab }),
() => tab.cancel(),
);
};

return (
<Button
variant="accent"
className={className}
onClick={() => {
// Checkout has no diff to gate on, but an out-of-range quantity is still
// one: it would be sent as a plan nobody can be billed for.
if (hasInvalidCustom) {
rejectInvalidAmount();
return;
}
if (!checkoutProducts.length) return;
// Opened from the click itself, and carried through both mutations:
// Stripe's URL only exists once the second answers, and a tab opened
// then has lost the user gesture that lets it through (see
// `openDeferredTab`). It is closed again if either step fails.
const tab = openDeferredTab();
withAiSpendCap(
() => createCheckout.mutate({ products: checkoutProducts }, { target: tab }),
() => tab.cancel(),
);
}}
onClick={handleCheckout}
loading={isPending}
disabled={isPending}
disabled={isPending || checkoutProducts.length === 0}
>
{SUBMIT_LABEL}
</Button>
Expand Down
17 changes: 17 additions & 0 deletions src/lib/relay/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,23 @@ const fetchRelay: FetchFunction = async (request, variables, cacheConfig, upload
return fetchRelay(request, variables, cacheConfig, uploadables);
}
}

// The server returned errors and no data. Relay would otherwise reject with
// its internal "No data returned for operation ..." message, which reaches
// the user verbatim through a mutation's `onError` toast (e.g. checkout on a
// mutation like `createCheckoutSession`, whose `CheckoutResult` payload has
// no `errors` field). Throw the readable server message so `onError` shows
// that instead. Partial responses (data present with field errors) fall
// through unchanged.
if (json.data == null) {
const message = json.errors
.map((error: { message?: string }) => error?.message)
.filter(Boolean)
.join('. ');
if (message) {
throw new Error(message);
}
}
}

return json;
Expand Down
Loading