Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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')
}
)
})
14 changes: 8 additions & 6 deletions src/features/home/__tests__/useHomeFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,20 @@ describe('useHomeFlow', () => {
expect(mockDisconnect).not.toHaveBeenCalled()
})

it('derives avatarName from the showFullName preference', () => {
mockUser = userWith({ showFullName: true, fullName: 'Kushagra S' })
expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S')
it('seeds avatarName from the username even when the full name is shown', () => {
// Home and the profile header must show the same letter: username
// "kush" with full name "Alice" is K on both, never A on one of them.
mockUser = userWith({ showFullName: true, fullName: 'Alice Example' })
expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('kush')

mockUser = userWith({ showFullName: false, fullName: 'Kushagra S' })
mockUser = userWith({ showFullName: false, fullName: 'Alice Example' })
expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('kush')

// usernameless: full name still seeds the initials
// usernameless: fall back to the full name so the circle carries a letter
mockUser = { user: { userId: 'u1', username: null, fullName: 'Kushagra S' } }
expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBe('Kushagra S')

mockUser = { user: { userId: 'u1', username: null } }
mockUser = { user: { userId: 'u1', username: null, fullName: null } }
expect(renderHook(() => useHomeFlow()).result.current.avatarName).toBeUndefined()
})
})
9 changes: 5 additions & 4 deletions src/features/home/useHomeFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ export function useHomeFlow() {
}
}, [isWagmiConnected, disconnectWagmi])

// respect the showFullName preference for the avatar initials; a
// usernameless user still gets initials from their full name (initials
// only — the preference governs showing the full name, not its initials)
const avatarName = (user?.user.showFullName && user?.user.fullName) || username || user?.user.fullName || undefined
// The avatar is the first letter of the username, on Home and on the
// profile header alike, whatever the showFullName preference (that governs
// the displayed name, not the avatar). A usernameless user falls back to
// their full name so the circle still carries a letter.
const avatarName = username || user?.user.fullName || undefined

return {
isPageLoading: isFetchingUser && !username,
Expand Down
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
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