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:
Steven Enamakel
2026-04-09 20:02:04 -07:00
committed by GitHub
parent 3d17bfb23d
commit 38eb934242
7 changed files with 606 additions and 14 deletions
@@ -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
View File
@@ -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');
});
});
+62 -5
View File
@@ -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) : [];
},
};
-5
View File
@@ -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"