Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
79fb1af
fix(android): drop the default OneSignal large icon — every push show…
abalinda Sep 2, 2026
5de5a0e
fix(avatar): own avatar shows the first letter of the username, not t…
abalinda Sep 2, 2026
82a1e0e
fix(about): the Terms of Service title follows the app language
abalinda Sep 2, 2026
03bb1e4
fix(about): every policy title follows the app language, not only the…
abalinda Sep 2, 2026
d06f79c
fix(avatar): seed the home avatar from the username, whatever showFul…
abalinda Sep 2, 2026
3c08191
Merge origin/dev into native-release-bug-fixes-aleks
abalinda Sep 2, 2026
daf5580
fix(notifications): one opt-in, one subscription — stop re-logging in…
abalinda Sep 3, 2026
53a97ae
fix(notifications): detect a new opt-in from the SDK's previous state…
abalinda Sep 3, 2026
dc45a54
fix(notifications): a new opt-in is the one false → true transition, …
abalinda Sep 3, 2026
d341285
fix(card): keep revealed details across an app switch; copy the expir…
abalinda Sep 3, 2026
b1815ba
Merge origin/dev into native-release-bug-fixes-aleks
abalinda Sep 3, 2026
361d90f
fix(card): expiry/cvv copy icons on the DS icon scale
abalinda Sep 3, 2026
5659eb6
fix(card): cover revealed card details while the app is backgrounded
abalinda Sep 3, 2026
b7bf1b3
fix(notifications): join the in-flight OneSignal login instead of sta…
abalinda Sep 3, 2026
d836cbe
Merge origin/dev into native-release-bug-fixes-aleks
abalinda Sep 3, 2026
624d56b
Update src/components/Card/CardFace.tsx
abalinda Sep 3, 2026
159e0b8
fix(profile): Personal details follows the name-visibility setting (T…
abalinda Sep 4, 2026
8cfa3b7
Merge origin/dev into native-release-bug-fixes-aleks
abalinda Sep 4, 2026
5a77f1a
fix: CI red from the dev merge, plus three review findings
abalinda Sep 4, 2026
c061b17
fix(notifications): guard the login commit and retry a joined login t…
abalinda Sep 4, 2026
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
Binary file not shown.
39 changes: 29 additions & 10 deletions src/components/Card/CardFace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ interface Props {
* the retry affordance — no separate dismiss needed. */
error?: string | null
onToggleReveal?: () => void
onCopy?: (value: string, field: 'pan' | 'cvv') => void
onCopy?: (value: string, field: CopyableCardField) => void
/** Pre-activation preview: PAN/cardholder/expiry rendered as `?`s.
* Used on AddCardEntryScreen before KYC + first spend. */
locked?: boolean
className?: string
}

export type CopyableCardField = 'pan' | 'expiry' | 'cvv'

const formatPan = (pan: string) => pan.replace(/(.{4})/g, '$1 ').trim()
const formatExpiry = (month: number, year: number) => `${String(month).padStart(2, '0')}/${String(year).slice(-2)}`

const CardFace: FC<Props> = ({
last4,
Expand All @@ -60,9 +63,9 @@ const CardFace: FC<Props> = ({
// so it never covers the PAN / expiry / CVV (or the loading skeletons).
// It slides back when the card is re-masked.
const detailsShown = showingDetails || loading
const [copiedField, setCopiedField] = useState<'pan' | 'cvv' | null>(null)
const [copiedField, setCopiedField] = useState<CopyableCardField | null>(null)

const handleCopy = (value: string, field: 'pan' | 'cvv') => {
const handleCopy = (value: string, field: CopyableCardField) => {
setCopiedField(field)
// Clear only if still showing the same field — guards against an
// earlier setTimeout overwriting a fresher copy on the other field.
Expand Down Expand Up @@ -160,13 +163,29 @@ const CardFace: FC<Props> = ({
)}
<div className="flex items-end justify-between">
<div className="text-s flex gap-6">
<div>
{/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */}
{/* ph-no-capture: expiry digits out of recordings. */}
<div className="ph-no-capture font-bold">
{String(revealed.expiryMonth).padStart(2, '0')}/
{String(revealed.expiryYear).slice(-2)}
<div className="flex items-end gap-1">
<div>
{/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */}
{/* ph-no-capture: expiry digits out of recordings. */}
<div className="ph-no-capture font-bold">
{formatExpiry(revealed.expiryMonth, revealed.expiryYear)}
</div>
</div>
{onCopy && (
<button
type="button"
aria-label={t('copyExpiry')}
onClick={() =>
handleCopy(
formatExpiry(revealed.expiryMonth, revealed.expiryYear),
'expiry'
)
}
className="p-1"
Comment thread
abalinda marked this conversation as resolved.
Outdated
>
<Icon name={copiedField === 'expiry' ? 'check' : 'copy'} size={16} />
</button>
)}
</div>
<div className="flex items-end gap-1">
<div>
Expand All @@ -181,7 +200,7 @@ const CardFace: FC<Props> = ({
onClick={() => handleCopy(revealed.cvv, 'cvv')}
className="relative p-1 transition-opacity duration-instant after:absolute after:-inset-3 focus-visible:outline-[3px] focus-visible:outline-action-focus active:opacity-60"
>
<Icon name={copiedField === 'cvv' ? 'check' : 'copy'} size={14} />
<Icon name={copiedField === 'cvv' ? 'check' : 'copy'} size={16} />
</button>
)}
</div>
Expand Down
12 changes: 9 additions & 3 deletions src/components/Card/YourCardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import ProfileMenuItem from '@/components/Profile/components/ProfileMenuItem'
import { Icon } from '@/components/Global/Icons/Icon'
import { Notification } from '@/components/0_Bruddle/Notification'
import { useToast } from '@/components/0_Bruddle/Toast'
import CardFace from '@/components/Card/CardFace'
import CardFace, { type CopyableCardField } from '@/components/Card/CardFace'
import CancelCardModal from '@/components/Card/CancelCardModal'
import LockCardModal from '@/components/Card/LockCardModal'
import { shouldShowAutoRenewBanner, daysUntilExpiry } from '@/components/Card/cardExpiry.utils'
Expand All @@ -31,6 +31,12 @@ interface Props {
onPrev?: () => void
}

const COPIED_MESSAGE_KEY: Record<CopyableCardField, 'cardNumberCopied' | 'expiryCopied' | 'cvvCopied'> = {
pan: 'cardNumberCopied',
expiry: 'expiryCopied',
cvv: 'cvvCopied',
}

const YourCardScreen: FC<Props> = ({ overview, card, onPrev }) => {
const t = useTranslations('card.yourCard')
const tGlobal = useTranslations('global')
Expand All @@ -50,13 +56,13 @@ const YourCardScreen: FC<Props> = ({ overview, card, onPrev }) => {
const balanceDueCents = cardBalanceDueCents(overview.balance?.spendingPower)

const handleCopy = useCallback(
async (value: string, field: 'pan' | 'cvv') => {
async (value: string, field: CopyableCardField) => {
if (!(await copyTextToClipboard(value))) {
toast.error(tGlobal('copyToClipboard.copyFailed'))
return
}
triggerHaptic()
toast.success(field === 'pan' ? t('cardNumberCopied') : t('cvvCopied'))
toast.success(t(COPIED_MESSAGE_KEY[field]))
},
[triggerHaptic, toast, t, tGlobal]
)
Expand Down
13 changes: 12 additions & 1 deletion src/components/Card/__tests__/CardFace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* degraded the Rain lookup).
*/
import React from 'react'
import { render as rtlRender, screen } from '@testing-library/react'
import { fireEvent, render as rtlRender, screen } from '@testing-library/react'
import { IntlWrapper } from '@/test-utils/intl'
import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace'

Expand All @@ -21,6 +21,17 @@ const revealed: RevealedCardDetails = {
cardholderName: 'Jane Doe',
}

describe('CardFace copy buttons', () => {
it('copies the expiry as MM/YY with its own button', () => {
const onCopy = jest.fn()
render(<CardFace last4="1234" revealed={revealed} onCopy={onCopy} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy expiry date' }))
expect(onCopy).toHaveBeenCalledWith('12/30', 'expiry')
fireEvent.click(screen.getByRole('button', { name: 'Copy CVV' }))
expect(onCopy).toHaveBeenCalledWith('123', 'cvv')
})
})

describe('CardFace cardholder name', () => {
it('shows the registered name when the card is revealed', () => {
render(<CardFace last4="1234" revealed={revealed} />)
Expand Down
34 changes: 34 additions & 0 deletions src/components/Profile/views/__tests__/About.view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
*/
import React from 'react'
import { fireEvent, render as rtlRender, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import { IntlWrapper } from '@/test-utils/intl'
import { loadMessages } from '@/i18n/app/messages'
import en from '@/i18n/app/messages/en.json'
import { AboutView } from '../About.view'

Expand Down Expand Up @@ -86,4 +88,36 @@ describe('AboutView', () => {
jest.useRealTimers()
}
})

// TASK-22146: every policy title follows the app language. The legal hrefs
// do not, so each language opens the same English documents; only the help
// link is locale-targeted, like every other DocsLink.
it.each([
['en', 'Terms of Service', '/en/help/security-disclosure'],
['es-419', 'Términos de servicio', '/es-419/help/security-disclosure'],
['pt-BR', 'Termos de Serviço', '/pt-br/help/security-disclosure'],
] as const)(
'in %s the policy titles follow the catalog and the legal hrefs stay put',
async (locale, termsTitle, helpHref) => {
const messages = await loadMessages(locale)
rtlRender(
<NextIntlClientProvider locale={locale} messages={messages} timeZone="UTC">
<AboutView appVersion="1.2.3" />
</NextIntlClientProvider>
)
const links = screen.getAllByRole('link')
expect(links.map((link) => link.textContent)).toEqual(Object.values(messages.profile.about.policies))
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/terms',
'/privacy',
'/card-terms-us',
'/card-terms-international',
'/card-esign',
'/card-privacy',
'/card-prohibited-activities',
helpHref,
])
expect(screen.getByRole('link', { name: termsTitle })).toHaveAttribute('href', '/terms')
}
)
})
34 changes: 34 additions & 0 deletions src/hooks/__tests__/useCardReveal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,40 @@ describe('useCardReveal', () => {
expect(captureSpy.mock.calls.at(-1)?.[1]?.error_message).not.toContain('correlationId')
})

it('covers details while hidden and shows them again on resume without a refetch', async () => {
mockedGetCardDetails.mockResolvedValueOnce(details)
const { result } = renderHook(() => useCardReveal({ cardId: 'c1', autoMaskMs: 0 }))
await act(async () => {
await result.current.reveal()
})

// Backgrounded: the task-switcher snapshot must not see the PAN.
act(() => {
Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
window.dispatchEvent(new Event('blur'))
})
expect(result.current.revealed).toBeNull()

// Back from the merchant app: same payload, no second (rate-limited) fetch.
act(() => {
Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true })
document.dispatchEvent(new Event('visibilitychange'))
})
expect(result.current.revealed).toEqual(details)
expect(mockedGetCardDetails).toHaveBeenCalledTimes(1)
})

it('does not mask on blur alone (native fires it spuriously)', async () => {
mockedGetCardDetails.mockResolvedValueOnce(details)
const { result } = renderHook(() => useCardReveal({ cardId: 'c1', autoMaskMs: 0 }))
await act(async () => {
await result.current.reveal()
})
act(() => window.dispatchEvent(new Event('blur')))
expect(result.current.revealed).toEqual(details)
})

it('auto-masks after the configured timeout', async () => {
jest.useFakeTimers()
mockedGetCardDetails.mockResolvedValueOnce(details)
Expand Down
64 changes: 64 additions & 0 deletions src/hooks/__tests__/useNotifications.loginDedupe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { act, renderHook, waitFor } from '@testing-library/react'

// Init publishes `oneSignalInitialized` before its first login() resolves, so a
// false → true opt-in that lands in that window used to start a second login()
// for the same id (lastLinkedExternalId is committed only after the first
// resolves) — the same double-record race TASK-22209 closed on the change
// listener. Pin: a sync for an id whose login is in flight joins it.

let resolveLogin: () => void = () => {}
const mockAdapter = {
init: jest.fn().mockResolvedValue(undefined),
login: jest.fn(
() =>
new Promise<void>((resolve) => {
resolveLogin = resolve
})
),
logout: jest.fn().mockResolvedValue(undefined),
requestPermission: jest.fn().mockResolvedValue('default'),
getPermission: jest.fn().mockResolvedValue('default'),
isOptedIn: jest.fn().mockResolvedValue(false),
onPermissionChange: jest.fn(() => () => {}),
onSubscriptionChange: jest.fn((_listener: (change: PushSubscriptionChange) => void) => () => {}),
onNotificationClick: jest.fn(() => () => {}),
}
jest.mock('@/services/onesignal', () => ({
getOneSignalAdapter: () => Promise.resolve(mockAdapter),
}))
jest.mock('@/utils/general.utils', () => ({
getUserPreferences: () => undefined,
updateUserPreferences: jest.fn(),
}))
jest.mock('@/utils/migration.utils', () => ({ isPwaSunsetOn: () => false }))
jest.mock('@/utils/demo', () => ({ isDemoMode: () => false }))
jest.mock('@/redux/hooks', () => ({ useUserStore: () => ({ user: { user: { userId: 'user-1' } } }) }))
jest.mock('posthog-js', () => ({ capture: jest.fn() }))
jest.mock('@sentry/nextjs', () => ({
addBreadcrumb: jest.fn(),
captureException: jest.fn(),
captureMessage: jest.fn(),
}))

import type { PushSubscriptionChange } from '@/services/onesignal'
import { useNotifications } from '../useNotifications'

describe('useNotifications initial login', () => {
it('joins the in-flight init login instead of starting a second one', async () => {
const rendered = renderHook(() => useNotifications())
await waitFor(() => expect(rendered.result.current.oneSignalInitialized).toBe(true))
await waitFor(() => expect(mockAdapter.login).toHaveBeenCalledTimes(1))
const onSubscriptionChange = mockAdapter.onSubscriptionChange.mock.calls[0][0]

// the opt-in lands while init's login() is still pending
await act(async () => {
onSubscriptionChange({ optedIn: true, previousOptedIn: false })
})
expect(mockAdapter.login).toHaveBeenCalledTimes(1)

await act(async () => {
resolveLogin()
})
expect(mockAdapter.login).toHaveBeenCalledTimes(1)
})
})
93 changes: 93 additions & 0 deletions src/hooks/__tests__/useNotifications.subscription.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { act, renderHook, waitFor } from '@testing-library/react'

// TASK-22209: OneSignal fires the push-subscription `change` event several
// times for one opt-in (opt-in flips, token registers, server assigns the id)
// and again on a token refresh after reload. The hook used to call login()
// and capture `notification_subscribed` on every `optedIn: true`, and the
// login re-registered the half-created subscription as a second record —
// which OneSignal greets with a second welcome notification. Pin: only the
// false → true opt-in transition acts; a real re-subscribe acts again.

const mockAdapter = {
init: jest.fn().mockResolvedValue(undefined),
login: jest.fn().mockResolvedValue(undefined),
logout: jest.fn().mockResolvedValue(undefined),
requestPermission: jest.fn().mockResolvedValue('default'),
getPermission: jest.fn().mockResolvedValue('default'),
isOptedIn: jest.fn().mockResolvedValue(false),
onPermissionChange: jest.fn(() => () => {}),
onSubscriptionChange: jest.fn((_listener: (change: PushSubscriptionChange) => void) => () => {}),
onNotificationClick: jest.fn(() => () => {}),
}
jest.mock('@/services/onesignal', () => ({
getOneSignalAdapter: () => Promise.resolve(mockAdapter),
}))
jest.mock('@/utils/general.utils', () => ({
getUserPreferences: () => undefined,
updateUserPreferences: jest.fn(),
}))
jest.mock('@/utils/migration.utils', () => ({ isPwaSunsetOn: () => false }))
jest.mock('@/utils/demo', () => ({ isDemoMode: () => false }))
jest.mock('@/redux/hooks', () => ({ useUserStore: () => ({ user: { user: { userId: 'user-1' } } }) }))
const mockCapture = jest.fn()
jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) }))
jest.mock('@sentry/nextjs', () => ({
addBreadcrumb: jest.fn(),
captureException: jest.fn(),
captureMessage: jest.fn(),
}))

import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import type { PushSubscriptionChange } from '@/services/onesignal'
import { useNotifications } from '../useNotifications'

const subscribedCaptures = () =>
mockCapture.mock.calls.filter(([event]) => event === ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIBED)

// the SDK's `change` events for one opt-in: the opt-in flips first (no token
// yet), then the token registers, then the server assigns the id — each with
// optedIn already true; later a reload refreshes the token the same way
const optedInFlipped: PushSubscriptionChange = { optedIn: true, previousOptedIn: false }
const tokenRegistered: PushSubscriptionChange = { optedIn: true, previousOptedIn: true }
const idAssigned: PushSubscriptionChange = { optedIn: true, previousOptedIn: true }
const tokenRefreshed: PushSubscriptionChange = { optedIn: true, previousOptedIn: true }
const optedOut: PushSubscriptionChange = { optedIn: false, previousOptedIn: true }
const optedBackIn: PushSubscriptionChange = { optedIn: true, previousOptedIn: false }

describe('useNotifications subscription change', () => {
it('acts once on one opt-in however many change events OneSignal splits it into', async () => {
const rendered = renderHook(() => useNotifications())
await waitFor(() => expect(rendered.result.current.oneSignalInitialized).toBe(true))
// init already linked the device to the user
expect(mockAdapter.login).toHaveBeenCalledTimes(1)
const onSubscriptionChange = mockAdapter.onSubscriptionChange.mock.calls[0][0]

await act(async () => {
onSubscriptionChange(optedInFlipped)
onSubscriptionChange(tokenRegistered)
onSubscriptionChange(idAssigned)
})

expect(rendered.result.current.isPushOptedIn).toBe(true)
expect(subscribedCaptures()).toHaveLength(1)
// no second login: the init link stands, nothing to retry
expect(mockAdapter.login).toHaveBeenCalledTimes(1)

// an already opted-in device refreshing its token on reload is not an opt-in
await act(async () => {
onSubscriptionChange(tokenRefreshed)
})
expect(subscribedCaptures()).toHaveLength(1)

// a real re-subscribe is a false → true transition and counts again
await act(async () => {
onSubscriptionChange(optedOut)
})
expect(rendered.result.current.isPushOptedIn).toBe(false)
await act(async () => {
onSubscriptionChange(optedBackIn)
})
expect(subscribedCaptures()).toHaveLength(2)
expect(mockAdapter.login).toHaveBeenCalledTimes(1)
})
})
Loading
Loading