mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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.
This commit is contained in:
@@ -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<CreditBalance | null>(null);
|
||||
const [redeemedCoupons, setRedeemedCoupons] = useState<RedeemedCoupon[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [submitSuccess, setSubmitSuccess] = useState<string | null>(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 (
|
||||
<section className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-sage-200 bg-sage-50 px-3 py-1 text-xs font-medium text-sage-700">
|
||||
Promo and reward codes
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-stone-900">Apply a reward code</h2>
|
||||
<p className="max-w-2xl text-sm text-stone-600">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
General credits
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{creditBalance ? formatUsd(creditBalance.balanceUsd) : loading ? '…' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Top-up credits
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">
|
||||
{creditBalance ? formatUsd(creditBalance.topUpBalanceUsd) : loading ? '…' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
|
||||
Redeemed codes
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-stone-900">{redeemedCoupons.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={event => {
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRedeem()}
|
||||
disabled={submitLoading || !couponCode.trim()}
|
||||
className="rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-700 disabled:opacity-50">
|
||||
{submitLoading ? 'Applying…' : 'Apply code'}
|
||||
</button>
|
||||
</div>
|
||||
{submitSuccess ? (
|
||||
<div className="rounded-xl border border-sage-200 bg-sage-50 px-3 py-2 text-sm text-sage-800">
|
||||
{submitSuccess}
|
||||
</div>
|
||||
) : null}
|
||||
{submitError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{submitError}
|
||||
</div>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<div className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-800">
|
||||
{loadError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCouponState()}
|
||||
className="ml-2 font-medium underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-stone-900">Recent redemptions</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCouponState()}
|
||||
disabled={loading}
|
||||
className="text-xs font-medium text-stone-500 transition-colors hover:text-stone-700 disabled:opacity-50">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && redeemedCoupons.length === 0 ? (
|
||||
<p className="text-sm text-stone-500">Loading reward history…</p>
|
||||
) : null}
|
||||
|
||||
{redeemedCoupons.length === 0 && !loading && !loadError ? (
|
||||
<p className="text-sm text-stone-500 rounded-xl border border-dashed border-stone-200 px-4 py-6 text-center">
|
||||
No reward codes redeemed yet.
|
||||
</p>
|
||||
) : redeemedCoupons.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-xl border border-stone-200">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead className="bg-stone-50 text-xs uppercase tracking-wide text-stone-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Code</th>
|
||||
<th className="px-3 py-2 font-medium">Reward</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Redeemed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{redeemedCoupons.map(coupon => (
|
||||
<tr
|
||||
key={`${coupon.code}-${coupon.redeemedAt ?? coupon.activationType}`}
|
||||
className="bg-white">
|
||||
<td className="px-3 py-2 font-mono text-stone-800">{coupon.code}</td>
|
||||
<td className="px-3 py-2 text-stone-700">{formatUsd(coupon.amountUsd)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${redemptionStatusClass(coupon)}`}>
|
||||
{redemptionStatus(coupon)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-stone-500">
|
||||
{formatDateTime(coupon.redeemedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default RewardsCouponSection;
|
||||
@@ -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(<RewardsCouponSection />);
|
||||
|
||||
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(<RewardsCouponSection />);
|
||||
|
||||
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(<RewardsCouponSection />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="min-h-full px-4 pt-6 pb-8">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<ReferralRewardsSection />
|
||||
<RewardsCouponSection />
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6">
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<CouponRedeemResult> => {
|
||||
return await callCoreCommand<CouponRedeemResult>('openhuman.billing_redeem_coupon', { code });
|
||||
const result = await callCoreCommand<unknown>('openhuman.billing_redeem_coupon', { code });
|
||||
return normalizeCouponRedeemResult(result);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -239,6 +295,7 @@ export const creditsApi = {
|
||||
* GET /coupons/me
|
||||
*/
|
||||
getUserCoupons: async (): Promise<RedeemedCoupon[]> => {
|
||||
return await callCoreCommand<RedeemedCoupon[]>('openhuman.billing_get_coupons');
|
||||
const coupons = await callCoreCommand<unknown[]>('openhuman.billing_get_coupons');
|
||||
return Array.isArray(coupons) ? coupons.map(normalizeRedeemedCoupon) : [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user