Skip to content
71 changes: 71 additions & 0 deletions apps/web/app/subscribe/celebrate/AccessStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/** @vitest-environment jsdom */
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@/components/confetti-burst", () => ({
ConfettiBurst: () => null,
}));

vi.mock("@/components/discord-button", () => ({
DiscordButton: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));

vi.mock("./access-status-helpers", () => ({
STATUS_POLL_MS: 8_000,
STATUS_MAX_ATTEMPTS: 0,
formatDate: () => null,
derivePhase: () => "loading",
deriveMessage: () => "Checking access...",
shouldContinuePolling: () => true,
PHASE_ICON: {
loading: "o",
syncing: "o",
success: "ok",
warning: "!",
error: "x",
},
PHASE_BANNER_CLASS: {
loading: "",
syncing: "",
success: " success",
warning: " warning",
error: " error",
},
}));

import { AccessStatus } from "./AccessStatus";

describe("AccessStatus", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
sessionStorage.clear();
});

it("renders the slow-processing note without a literal HTML entity", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
ok: true,
grant: null,
roleSync: null,
sandbox: { isSandbox: false },
}),
}),
);

render(<AccessStatus guildId="guild_1" isFresh={false} />);

expect(
await screen.findByText(
/payment is confirmed - you can safely close this page and check back later\./,
),
).toBeInTheDocument();
expect(screen.queryByText(/&mdash;/)).not.toBeInTheDocument();
});
});
35 changes: 29 additions & 6 deletions apps/web/app/subscribe/celebrate/AccessStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PHASE_ICON,
PHASE_BANNER_CLASS,
} from "./access-status-helpers";
import { getSandboxCelebrateNotice, shouldShowSandboxCelebrateNote } from "./sandboxSummary";

/** Scoped per-tier so separate checkouts each get their own celebration. */
const CELEBRATE_KEY_PREFIX = "celebrate_fresh_consumed:";
Expand Down Expand Up @@ -95,6 +96,8 @@ export function AccessStatus({
const phase = derivePhase(data, error);
const message = deriveMessage(data, error, phase);
const grantDate = formatDate(data?.grant?.validThrough ?? null);
const sandboxNotice = getSandboxCelebrateNotice(data);
const shouldShowSandboxSuccessNote = shouldShowSandboxCelebrateNote(data, phase);

/* Don't let transient errors halt polling — loadStatus() clears error on
the next successful response, so we keep polling through failures. */
Expand Down Expand Up @@ -186,13 +189,33 @@ export function AccessStatus({

{/* Failure guidance */}
{phase === "warning" &&
roleSyncStatus === "failed" &&
(roleSyncStatus === "failed" ||
roleSyncStatus === "pending" ||
roleSyncStatus === "in_progress") &&
data?.grant?.status !== "past_due" && (
<p className="text-xs text-muted-foreground">
We&apos;ll automatically retry syncing your Discord role. If this persists, contact
the server administrator.
</p>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{roleSyncStatus === "failed"
? "We'll automatically retry syncing your Discord role. If this persists, contact the server administrator."
: "Your entitlement is active, and Discord role sync is still processing in the background."}
</p>
{roleSyncStatus === "failed" && data?.roleSync?.lastError && (
<p className="text-xs text-muted-foreground">
Current issue: {data.roleSync.lastError}
</p>
)}
{shouldShowSandboxSuccessNote && sandboxNotice && (
<p className="text-xs text-muted-foreground" data-testid="sandbox-success-note">
{sandboxNotice}
</p>
)}
</div>
)}
{phase === "success" && shouldShowSandboxSuccessNote && sandboxNotice && (
<p className="text-xs text-muted-foreground" data-testid="sandbox-success-note">
{sandboxNotice}
</p>
)}
{phase === "warning" && data?.grant?.status === "past_due" && (
<p className="text-xs text-muted-foreground">
Your payment method may need updating. Please check your billing details.
Expand All @@ -201,7 +224,7 @@ export function AccessStatus({
{pollExhausted && phase !== "warning" && phase !== "error" && (
<p className="text-xs text-muted-foreground">
This is taking longer than usual. Your access will be activated automatically once
payment is confirmed &mdash; you can safely close this page and check back later.
payment is confirmed - you can safely close this page and check back later.
</p>
)}
</div>
Expand Down
19 changes: 17 additions & 2 deletions apps/web/app/subscribe/celebrate/access-status-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export type StatusResponse = {
ok: true;
discordUserId: string;
discordGuildId: string;
sandbox: {
isSandbox: boolean;
provider: "stripe" | "authorize_net" | "nmi" | null;
providerLabel: string | null;
};
tier: {
id: string;
slug: string;
Expand Down Expand Up @@ -65,6 +70,12 @@ export function derivePhase(data: StatusResponse | null, error: string | null):
const roleStatus = data.roleSync?.status ?? null;

if (grantStatus === "active") {
if (
data.sandbox.isSandbox &&
(roleStatus === "pending" || roleStatus === "in_progress" || roleStatus === "failed")
) {
return "warning";
}
if (roleStatus === "pending" || roleStatus === "in_progress") return "syncing";
if (roleStatus === "failed") return "warning";
return "success";
Expand Down Expand Up @@ -94,10 +105,14 @@ export function deriveMessage(
? "Syncing your Discord role..."
: "Processing...";
case "success":
return "Access active";
return data.sandbox.isSandbox ? "Access active in sandbox" : "Access active";
case "warning":
if (data.grant.status === "past_due") return "Payment needs attention";
return "Role sync issue \u2014 we'll retry automatically";
return data.sandbox.isSandbox
? roleStatus === "pending" || roleStatus === "in_progress"
? "Sandbox access active \u2014 role sync is still pending"
: "Sandbox access active \u2014 role sync still needs attention"
: "Role sync issue \u2014 we'll retry automatically";
case "error":
if (data.grant.status === "suspended_dispute") return "Access paused due to a dispute";
return "Access inactive";
Expand Down
113 changes: 113 additions & 0 deletions apps/web/app/subscribe/celebrate/sandboxSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { getSandboxCelebrateNotice, shouldShowSandboxCelebrateNote } from "./sandboxSummary";
import type { StatusResponse } from "./access-status-helpers";

const makeStatus = (overrides: Partial<StatusResponse> = {}): StatusResponse => ({
ok: true,
discordUserId: "user_1",
discordGuildId: "guild_1",
sandbox: {
isSandbox: true,
provider: "authorize_net",
providerLabel: "Authorize.Net",
},
tier: {
id: "tier_1",
slug: "starter",
name: "Starter",
displayPrice: "$5 / month",
},
grant: {
id: "grant_1",
status: "active",
validFrom: 1,
validThrough: null,
source: "authorize_net_subscription",
updatedAt: 2,
},
roleSync: {
id: "sync_1",
status: "failed",
requestedAt: 1,
updatedAt: 2,
lastError: "Missing role permissions",
},
...overrides,
});

describe("sandbox celebrate summary", () => {
it("returns additive sandbox copy for sandbox grant results", () => {
expect(getSandboxCelebrateNotice(makeStatus())).toBe(
"Authorize.Net sandbox is active. Your entitlement succeeded for testing, and no real payment occurred.",
);
});

it("shows sandbox note for success-with-warning role sync failures", () => {
expect(shouldShowSandboxCelebrateNote(makeStatus(), "warning")).toBe(true);
});

it("shows sandbox note while sandbox role sync is still pending", () => {
expect(
shouldShowSandboxCelebrateNote(
makeStatus({
roleSync: {
id: "sync_3",
status: "pending",
requestedAt: 1,
updatedAt: 2,
lastError: null,
},
}),
"warning",
),
).toBe(true);
});

it("shows sandbox note for full success", () => {
expect(
shouldShowSandboxCelebrateNote(
makeStatus({
roleSync: {
id: "sync_2",
status: "completed",
requestedAt: 1,
updatedAt: 2,
lastError: null,
},
}),
"success",
),
).toBe(true);
});

it("does not show sandbox note outside sandbox or for billing warnings", () => {
expect(
shouldShowSandboxCelebrateNote(
makeStatus({
sandbox: {
isSandbox: false,
provider: null,
providerLabel: null,
},
}),
"warning",
),
).toBe(false);

expect(
shouldShowSandboxCelebrateNote(
makeStatus({
grant: {
id: "grant_2",
status: "past_due",
validFrom: 1,
validThrough: null,
source: "authorize_net_subscription",
updatedAt: 2,
},
}),
"warning",
),
).toBe(false);
});
});
33 changes: 33 additions & 0 deletions apps/web/app/subscribe/celebrate/sandboxSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { formatCelebrateSandboxNotice } from "@/lib/subscribeSandbox";
import type { StatusResponse } from "./access-status-helpers";

export function getSandboxCelebrateNotice(data: StatusResponse | null): string | null {
if (!data?.sandbox.isSandbox || !data.sandbox.provider || !data.sandbox.providerLabel) {
return null;
}

return formatCelebrateSandboxNotice({
isSandbox: true,
provider: data.sandbox.provider,
providerLabel: data.sandbox.providerLabel,
});
}

export function shouldShowSandboxCelebrateNote(
data: StatusResponse | null,
phase: string,
): boolean {
if (!data?.sandbox.isSandbox || !data.grant) {
return false;
}

if (data.grant.status === "past_due") {
return false;
}

if (phase === "success" || phase === "warning") {
return true;
}

return false;
}
Loading
Loading