From 38eb9342425dfead543222ab9dc59c248cf93159 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:02:04 -0700 Subject: [PATCH] Add coupon redemption to Rewards page (#471) * Add coupon redemption to Rewards page * Format Rewards coupon changes * refactor: enhance coupon redemption logic and error handling in Rewards section - Updated the coupon redemption process to utilize Promise.allSettled for improved error handling during state refresh. - Adjusted the display logic for redeemed coupons to ensure clarity when no rewards are available. - Refactored the normalization of coupon redeem results to streamline data extraction from the API response. These changes improve the robustness and user experience of the Rewards feature. * test: add unit tests for coupon redemption and balance display in Rewards section - Introduced a new test case to validate the display of pending coupon messages and ensure the current balance remains unchanged until the reward is fulfilled. - Enhanced existing tests for the `redeemCoupon` function to verify the unwrapping of nested success/data payloads. - Updated the `RewardsCouponSection` component to improve the conditional rendering logic for better clarity. These changes enhance test coverage and ensure the correctness of coupon handling in the Rewards feature. * test: simplify data structure in redeemCoupon test case - Refactored the `redeemCoupon` test case to streamline the data structure for the success response, enhancing readability and maintainability. - This change improves the clarity of the test while ensuring it continues to validate the unwrapping of nested success/data payloads effectively. --- .../rewards/RewardsCouponSection.tsx | 296 ++++++++++++++++++ .../__tests__/RewardsCouponSection.test.tsx | 138 ++++++++ .../panels/billing/PayAsYouGoCard.tsx | 7 +- app/src/pages/Rewards.tsx | 2 + .../services/api/__tests__/creditsApi.test.ts | 105 +++++++ app/src/services/api/creditsApi.ts | 67 +++- yarn.lock | 5 - 7 files changed, 606 insertions(+), 14 deletions(-) create mode 100644 app/src/components/rewards/RewardsCouponSection.tsx create mode 100644 app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx create mode 100644 app/src/services/api/__tests__/creditsApi.test.ts diff --git a/app/src/components/rewards/RewardsCouponSection.tsx b/app/src/components/rewards/RewardsCouponSection.tsx new file mode 100644 index 000000000..3d8fee0fa --- /dev/null +++ b/app/src/components/rewards/RewardsCouponSection.tsx @@ -0,0 +1,296 @@ +import createDebug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useUser } from '../../hooks/useUser'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { + type CouponRedeemResult, + type CreditBalance, + creditsApi, + type RedeemedCoupon, +} from '../../services/api/creditsApi'; + +const log = createDebug('openhuman:rewards-coupons'); + +function formatUsd(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); +} + +function formatDateTime(value: string | null): string { + if (!value) return 'Pending'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Pending' : date.toLocaleString(); +} + +function redemptionStatus(coupon: RedeemedCoupon): string { + if (coupon.fulfilled) return 'Applied'; + if (coupon.activationType === 'CONDITIONAL') return 'Pending action'; + return 'Redeemed'; +} + +function redemptionStatusClass(coupon: RedeemedCoupon): string { + if (coupon.fulfilled) return 'bg-sage-100 text-sage-700'; + if (coupon.activationType === 'CONDITIONAL') return 'bg-amber-50 text-amber-800'; + return 'bg-stone-100 text-stone-700'; +} + +function successMessage(result: CouponRedeemResult): string { + if (result.pending) { + return `${result.couponCode} accepted. ${formatUsd(result.amountUsd)} will unlock after the required action is completed.`; + } + return `${result.couponCode} redeemed. ${formatUsd(result.amountUsd)} was added to your credits.`; +} + +const RewardsCouponSection = () => { + const { snapshot } = useCoreState(); + const { refetch } = useUser(); + const token = snapshot.sessionToken; + + const [couponCode, setCouponCode] = useState(''); + const [creditBalance, setCreditBalance] = useState(null); + const [redeemedCoupons, setRedeemedCoupons] = useState([]); + const [loading, setLoading] = useState(false); + const [submitLoading, setSubmitLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [submitSuccess, setSubmitSuccess] = useState(null); + const latestRequestIdRef = useRef(0); + + const loadCouponState = useCallback(async () => { + if (!token) { + latestRequestIdRef.current += 1; + setCreditBalance(null); + setRedeemedCoupons([]); + setLoadError(null); + setLoading(false); + return; + } + + latestRequestIdRef.current += 1; + const requestId = latestRequestIdRef.current; + setLoading(true); + setLoadError(null); + + try { + log('[load] fetching balance and coupon history'); + const [balance, coupons] = await Promise.all([ + creditsApi.getBalance(), + creditsApi.getUserCoupons(), + ]); + + if (requestId !== latestRequestIdRef.current) return; + + log('[load] loaded balance=%O coupons=%d', balance, coupons.length); + setCreditBalance(balance); + setRedeemedCoupons(coupons); + } catch (error) { + if (requestId !== latestRequestIdRef.current) return; + const message = + error && typeof error === 'object' && 'error' in error + ? String((error as { error: unknown }).error) + : 'Could not load reward codes right now.'; + log('[load] failed: %s', message); + setLoadError(message); + } finally { + if (requestId === latestRequestIdRef.current) { + setLoading(false); + } + } + }, [token]); + + useEffect(() => { + void loadCouponState(); + }, [loadCouponState]); + + const handleRedeem = async () => { + const code = couponCode.trim(); + if (!code || submitLoading) return; + + setSubmitLoading(true); + setSubmitError(null); + setSubmitSuccess(null); + + try { + log('[redeem] submitting code=%s', code); + const result = await creditsApi.redeemCoupon(code); + setSubmitSuccess(successMessage(result)); + setCouponCode(''); + + const refreshResults = await Promise.allSettled([loadCouponState(), refetch()]); + const refreshFailures = refreshResults.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + if (refreshFailures.length > 0) { + log('[redeem] refresh failed count=%d', refreshFailures.length); + } + + log( + '[redeem] completed code=%s pending=%s amount=%s', + result.couponCode, + result.pending, + result.amountUsd + ); + } catch (error) { + const message = + error && typeof error === 'object' && 'error' in error + ? String((error as { error: unknown }).error) + : 'Could not apply that reward code.'; + log('[redeem] failed: %s', message); + setSubmitError(message); + } finally { + setSubmitLoading(false); + } + }; + + if (!token) { + return null; + } + + return ( +
+
+
+ Promo and reward codes +
+

Apply a reward code

+

+ Redeem promo or campaign codes here. Referral attribution stays in the referral section + above. Successful redemptions refresh your credits immediately, and pending rewards stay + visible in your history. +

+
+ +
+
+
+ General credits +
+
+ {creditBalance ? formatUsd(creditBalance.balanceUsd) : loading ? '…' : '—'} +
+
+
+
+ Top-up credits +
+
+ {creditBalance ? formatUsd(creditBalance.topUpBalanceUsd) : loading ? '…' : '—'} +
+
+
+
+ Redeemed codes +
+
{redeemedCoupons.length}
+
+
+ +
+
+ { + setCouponCode(event.target.value.toUpperCase()); + if (submitError) setSubmitError(null); + if (submitSuccess) setSubmitSuccess(null); + }} + onKeyDown={event => { + if (event.key === 'Enter') { + void handleRedeem(); + } + }} + placeholder="Promo code" + disabled={submitLoading} + className="flex-1 px-4 py-2.5 rounded-xl border border-stone-200 bg-white font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-primary-500/40" + /> + +
+ {submitSuccess ? ( +
+ {submitSuccess} +
+ ) : null} + {submitError ? ( +
+ {submitError} +
+ ) : null} + {loadError ? ( +
+ {loadError} + +
+ ) : null} +
+ +
+
+

Recent redemptions

+ +
+ + {loading && redeemedCoupons.length === 0 ? ( +

Loading reward history…

+ ) : null} + + {redeemedCoupons.length === 0 && !loading && !loadError ? ( +

+ No reward codes redeemed yet. +

+ ) : redeemedCoupons.length > 0 ? ( +
+ + + + + + + + + + + {redeemedCoupons.map(coupon => ( + + + + + + + ))} + +
CodeRewardStatusRedeemed
{coupon.code}{formatUsd(coupon.amountUsd)} + + {redemptionStatus(coupon)} + + + {formatDateTime(coupon.redeemedAt)} +
+
+ ) : null} +
+
+ ); +}; + +export default RewardsCouponSection; diff --git a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx new file mode 100644 index 000000000..7cea75897 --- /dev/null +++ b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import RewardsCouponSection from '../RewardsCouponSection'; + +const mocks = vi.hoisted(() => ({ + mockUseCoreState: vi.fn(), + mockUseUser: vi.fn(), + mockCreditsApi: { getBalance: vi.fn(), getUserCoupons: vi.fn(), redeemCoupon: vi.fn() }, +})); + +vi.mock('../../../providers/CoreStateProvider', () => ({ + useCoreState: () => mocks.mockUseCoreState(), +})); + +vi.mock('../../../hooks/useUser', () => ({ useUser: () => mocks.mockUseUser() })); + +vi.mock('../../../services/api/creditsApi', () => ({ creditsApi: mocks.mockCreditsApi })); + +describe('RewardsCouponSection', () => { + const refetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockUseCoreState.mockReturnValue({ snapshot: { sessionToken: 'test-token' } }); + mocks.mockUseUser.mockReturnValue({ refetch }); + }); + + it('loads balances and refreshes history after a successful redemption', async () => { + mocks.mockCreditsApi.getBalance + .mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 1, topUpBaselineUsd: null }) + .mockResolvedValueOnce({ balanceUsd: 8, topUpBalanceUsd: 1, topUpBaselineUsd: null }); + mocks.mockCreditsApi.getUserCoupons + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + code: 'APRL-2026', + amountUsd: 5, + redeemedAt: '2026-04-09T19:00:00.000Z', + activationType: 'IMMEDIATE', + activationCondition: null, + fulfilled: true, + fulfilledAt: '2026-04-09T19:00:01.000Z', + }, + ]); + mocks.mockCreditsApi.redeemCoupon.mockResolvedValueOnce({ + couponCode: 'APRL-2026', + amountUsd: 5, + pending: false, + }); + + render(); + + expect(await screen.findByText('$3.00')).toBeInTheDocument(); + expect(screen.getByText('No reward codes redeemed yet.')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'aprl-2026' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply code' })); + + expect( + await screen.findByText('APRL-2026 redeemed. $5.00 was added to your credits.') + ).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('$8.00')).toBeInTheDocument(); + }); + expect(screen.getByText('APRL-2026')).toBeInTheDocument(); + expect(screen.getByText('Applied')).toBeInTheDocument(); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('shows backend redemption errors without clearing the existing state', async () => { + mocks.mockCreditsApi.getBalance.mockResolvedValue({ + balanceUsd: 3, + topUpBalanceUsd: 0, + topUpBaselineUsd: null, + }); + mocks.mockCreditsApi.getUserCoupons.mockResolvedValue([]); + mocks.mockCreditsApi.redeemCoupon.mockRejectedValueOnce({ + error: 'This coupon has already been used.', + }); + + render(); + + expect(await screen.findByText('$3.00')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'used-code' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply code' })); + + expect(await screen.findByText('This coupon has already been used.')).toBeInTheDocument(); + expect(mocks.mockCreditsApi.getBalance).toHaveBeenCalledTimes(1); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('shows pending coupon copy and keeps the current balance until the reward is fulfilled', async () => { + mocks.mockCreditsApi.getBalance + .mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null }) + .mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null }); + mocks.mockCreditsApi.getUserCoupons + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + code: 'APRL-2026', + amountUsd: 5, + redeemedAt: '2026-04-09T19:00:00.000Z', + activationType: 'CONDITIONAL', + activationCondition: 'SUBSCRIBE_PAID_PLAN', + fulfilled: false, + fulfilledAt: null, + }, + ]); + mocks.mockCreditsApi.redeemCoupon.mockResolvedValueOnce({ + couponCode: 'APRL-2026', + amountUsd: 5, + pending: true, + }); + + render(); + + expect(await screen.findByText('$3.00')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('Promo code'), { target: { value: 'aprl-2026' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply code' })); + + expect( + await screen.findByText( + 'APRL-2026 accepted. $5.00 will unlock after the required action is completed.' + ) + ).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getAllByText('$3.00')).toHaveLength(1); + }); + expect(screen.getByText('APRL-2026')).toBeInTheDocument(); + expect(screen.getByText('Pending action')).toBeInTheDocument(); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx index 47a6b7629..34688d1f5 100644 --- a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx +++ b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx @@ -37,11 +37,10 @@ const PayAsYouGoCard = ({ try { log('[coupon] redeeming code=%s', code); const result = await creditsApi.redeemCoupon(code); - const amount = result?.data?.amountUsd; setCouponSuccess( - amount != null - ? `Coupon redeemed! $${amount.toFixed(2)} added to your credits.` - : 'Coupon redeemed successfully!' + result.pending + ? `Coupon accepted. $${result.amountUsd.toFixed(2)} will be added after the required action.` + : `Coupon redeemed! $${result.amountUsd.toFixed(2)} added to your credits.` ); setCouponCode(''); onBalanceRefresh(); diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index 424c8dea9..5f1dff9af 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import ReferralRewardsSection from '../components/referral/ReferralRewardsSection'; +import RewardsCouponSection from '../components/rewards/RewardsCouponSection'; import { useUser } from '../hooks/useUser'; import { useAppSelector } from '../store/hooks'; @@ -91,6 +92,7 @@ const Rewards = () => {
+
diff --git a/app/src/services/api/__tests__/creditsApi.test.ts b/app/src/services/api/__tests__/creditsApi.test.ts new file mode 100644 index 000000000..9fbe7c875 --- /dev/null +++ b/app/src/services/api/__tests__/creditsApi.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockCallCoreCommand = vi.fn(); + +vi.mock('../../coreCommandClient', () => ({ + callCoreCommand: (...args: unknown[]) => mockCallCoreCommand(...args), +})); + +const { creditsApi, normalizeCouponRedeemResult, normalizeRedeemedCoupon } = + await import('../creditsApi'); + +describe('creditsApi coupon helpers', () => { + beforeEach(() => { + mockCallCoreCommand.mockReset(); + }); + + it('normalizes redeem payloads from backend data shape', () => { + expect( + normalizeCouponRedeemResult({ couponCode: 'SAVE-2026', amount_usd: '4.5', pending: 0 }) + ).toEqual({ couponCode: 'SAVE-2026', amountUsd: 4.5, pending: false }); + }); + + it('normalizes redeemed coupon rows', () => { + expect( + normalizeRedeemedCoupon({ + code: 'HELLO123', + amountUsd: '7.25', + redeemed_at: '2026-04-09T12:00:00.000Z', + activation_type: 'CONDITIONAL', + activation_condition: 'SUBSCRIBE_PAID_PLAN', + fulfilled: false, + }) + ).toEqual({ + code: 'HELLO123', + amountUsd: 7.25, + redeemedAt: '2026-04-09T12:00:00.000Z', + activationType: 'CONDITIONAL', + activationCondition: 'SUBSCRIBE_PAID_PLAN', + fulfilled: false, + fulfilledAt: null, + }); + }); + + it('redeemCoupon unwraps and normalizes the core RPC payload', async () => { + mockCallCoreCommand.mockResolvedValueOnce({ + couponCode: 'APRL-2026', + amountUsd: 5, + pending: true, + }); + + await expect(creditsApi.redeemCoupon('APRL-2026')).resolves.toEqual({ + couponCode: 'APRL-2026', + amountUsd: 5, + pending: true, + }); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_redeem_coupon', { + code: 'APRL-2026', + }); + }); + + it('redeemCoupon also unwraps nested success/data payloads', async () => { + mockCallCoreCommand.mockResolvedValueOnce({ + success: true, + data: { code: 'APRL-2026', amountUsd: 5, pending: false }, + }); + + await expect(creditsApi.redeemCoupon('APRL-2026')).resolves.toEqual({ + couponCode: 'APRL-2026', + amountUsd: 5, + pending: false, + }); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_redeem_coupon', { + code: 'APRL-2026', + }); + }); + + it('getUserCoupons normalizes coupon history rows', async () => { + mockCallCoreCommand.mockResolvedValueOnce([ + { + code: 'WELCOME', + amountUsd: 3, + redeemedAt: '2026-04-09T08:00:00.000Z', + activationType: 'IMMEDIATE', + fulfilled: true, + fulfilledAt: '2026-04-09T08:00:01.000Z', + }, + ]); + + await expect(creditsApi.getUserCoupons()).resolves.toEqual([ + { + code: 'WELCOME', + amountUsd: 3, + redeemedAt: '2026-04-09T08:00:00.000Z', + activationType: 'IMMEDIATE', + activationCondition: null, + fulfilled: true, + fulfilledAt: '2026-04-09T08:00:01.000Z', + }, + ]); + + expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_coupons'); + }); +}); diff --git a/app/src/services/api/creditsApi.ts b/app/src/services/api/creditsApi.ts index 1d6c78200..2c2b3c0ab 100644 --- a/app/src/services/api/creditsApi.ts +++ b/app/src/services/api/creditsApi.ts @@ -114,16 +114,71 @@ export interface UpdateCardPayload { // ── Coupon types ──────────────────────────────────────────────────────────── export interface CouponRedeemResult { - success: boolean; - data: { code: string; amountUsd: number }; + couponCode: string; + amountUsd: number; + pending: boolean; } export interface RedeemedCoupon { code: string; amountUsd: number; - redeemedAt: string; + redeemedAt: string | null; activationType: string; fulfilled: boolean; + fulfilledAt: string | null; + activationCondition: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asNumber(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return 0; +} + +function asStringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +export function normalizeCouponRedeemResult(raw: unknown): CouponRedeemResult { + const record = asRecord(raw) ?? {}; + const envelopeData = asRecord(record.data); + const payload = envelopeData ?? record; + return { + couponCode: + (typeof payload.couponCode === 'string' && payload.couponCode.trim()) || + (typeof payload.code === 'string' && payload.code.trim()) || + '', + amountUsd: asNumber(payload.amountUsd ?? payload.amount_usd), + pending: Boolean(payload.pending), + }; +} + +export function normalizeRedeemedCoupon(raw: unknown): RedeemedCoupon { + const record = asRecord(raw) ?? {}; + return { + code: + (typeof record.code === 'string' && record.code.trim()) || + (typeof record.couponCode === 'string' && record.couponCode.trim()) || + '', + amountUsd: asNumber(record.amountUsd ?? record.amount_usd), + redeemedAt: asStringOrNull(record.redeemedAt ?? record.redeemed_at), + activationType: + (typeof record.activationType === 'string' && record.activationType.trim()) || + (typeof record.activation_type === 'string' && record.activation_type.trim()) || + 'IMMEDIATE', + fulfilled: Boolean(record.fulfilled), + fulfilledAt: asStringOrNull(record.fulfilledAt ?? record.fulfilled_at), + activationCondition: asStringOrNull(record.activationCondition ?? record.activation_condition), + }; } /** @@ -231,7 +286,8 @@ export const creditsApi = { * POST /coupons/redeem */ redeemCoupon: async (code: string): Promise => { - return await callCoreCommand('openhuman.billing_redeem_coupon', { code }); + const result = await callCoreCommand('openhuman.billing_redeem_coupon', { code }); + return normalizeCouponRedeemResult(result); }, /** @@ -239,6 +295,7 @@ export const creditsApi = { * GET /coupons/me */ getUserCoupons: async (): Promise => { - return await callCoreCommand('openhuman.billing_get_coupons'); + const coupons = await callCoreCommand('openhuman.billing_get_coupons'); + return Array.isArray(coupons) ? coupons.map(normalizeRedeemedCoupon) : []; }, }; diff --git a/yarn.lock b/yarn.lock index a2a1b7ad5..929ce5bdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -652,11 +652,6 @@ resolved "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.0.tgz" integrity sha512-YiY1OmY6Qhkvmly8vZiD8wZRpW/npGZNg+0Sk8mstxirRHCg6lolHt5tSODCfuNPE/fBsAqRwDJE417x7jDDHA== -"@heroicons/react@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.2.0.tgz#0c05124af50434a800773abec8d3af6a297d904b" - integrity sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ== - "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"