Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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="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 === '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 @@ -32,6 +32,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 Down Expand Up @@ -65,13 +71,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
76 changes: 59 additions & 17 deletions src/components/Profile/components/ShowNameToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,77 @@

import { updateUserById } from '@/app/actions/users'
import { Toggle } from '@/components/0_Bruddle/Toggle'
import { useToast } from '@/components/0_Bruddle/Toast'
import ActionModal from '@/components/Global/ActionModal'
import { useAuth } from '@/context/authContext'
import { useTranslations } from 'next-intl'
import { useState } from 'react'

const ShowNameToggle = () => {
interface ShowNameToggleProps {
checked: boolean
/** Called with the optimistic value, so the screen reflects the setting at once. */
onChange: (value: boolean) => void
}

const ShowNameToggle = ({ checked, onChange }: ShowNameToggleProps) => {
const t = useTranslations('profile')
const tCommon = useTranslations('common')
const { fetchUser, user } = useAuth()
const [showFullName, setShowFullName] = useState(user?.user.showFullName ?? false)
const toast = useToast()
const [isConfirming, setIsConfirming] = useState(false)

const handleToggleChange = async () => {
const newValue = !showFullName
setShowFullName(newValue)
const save = async (newValue: boolean) => {
onChange(newValue)
Comment thread
abalinda marked this conversation as resolved.
Comment thread
abalinda marked this conversation as resolved.

// Fire-and-forget: don't await fetchUser() to allow quick navigation
updateUserById({
// updateUserById RESOLVES { error } for a non-2xx or a network failure —
// it never rejects, so a catch block would let a failed save stand and
// this screen would claim the legal name is hidden while it is public.
const { error } = await updateUserById({
userId: user?.user.userId,
showFullName: newValue,
})
.then(() => {
// Refetch user data in background without blocking
fetchUser()
})
.catch((error) => {
console.error('Failed to update preferences:', error)
// Revert on error
setShowFullName(!newValue)
})
if (error) {
onChange(!newValue)
toast.error(tCommon('genericError'))
return
}
// Refetch user data in background without blocking
fetchUser()
}
return <Toggle checked={showFullName} onChange={handleToggleChange} aria-label={t('menu.showMyFullName')} />

// Turning it on publishes the legal name next to the username, so it asks
// first. Turning it off takes nothing away and needs no confirmation.
const handleToggleChange = () => (checked ? void save(false) : setIsConfirming(true))

return (
<>
<Toggle checked={checked} onChange={handleToggleChange} aria-label={t('menu.showMyFullName')} />
<ActionModal
visible={isConfirming}
onClose={() => setIsConfirming(false)}
tone="warning"
icon="eye"
title={t('showFullNameConfirm.title')}
description={t('showFullNameConfirm.description')}
ctas={[
{
text: tCommon('confirm'),
variant: 'purple',
shadowSize: '4',
onClick: () => {
setIsConfirming(false)
void save(true)
},
},
{
text: tCommon('cancel'),
variant: 'stroke',
onClick: () => setIsConfirming(false),
},
]}
/>
</>
)
}

export default ShowNameToggle
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* ShowNameToggle — the confirmation gate. Turning the setting ON publishes the
* user's legal name next to their username, so it must ask first; turning it
* OFF saves straight away.
*/
import React from 'react'
import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react'
import { IntlWrapper } from '@/test-utils/intl'
import ShowNameToggle from '@/components/Profile/components/ShowNameToggle'

const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: IntlWrapper })

const mockUpdateUserById = jest.fn()
const mockFetchUser = jest.fn()

jest.mock('@/app/actions/users', () => ({ updateUserById: (...a: unknown[]) => mockUpdateUserById(...a) }))
const mockToastError = jest.fn()
jest.mock('@/components/0_Bruddle/Toast', () => ({ useToast: () => ({ error: mockToastError }) }))
jest.mock('@/context/authContext', () => ({
useAuth: () => ({ fetchUser: mockFetchUser, user: { user: { userId: 'u1' } } }),
}))
jest.mock('@/components/Global/ActionModal', () => ({
__esModule: true,
default: ({ visible, title, ctas }: any) =>
visible ? (
<div data-testid="modal">
<h1>{title}</h1>
{ctas?.map((c: any, i: number) => (
<button key={i} onClick={c.onClick}>
{c.text}
</button>
))}
</div>
) : null,
}))

beforeEach(() => {
jest.clearAllMocks()
mockUpdateUserById.mockResolvedValue({ data: {} })
})

describe('ShowNameToggle', () => {
it('asks before turning the setting on, and saves once confirmed', async () => {
const onChange = jest.fn()
render(<ShowNameToggle checked={false} onChange={onChange} />)

fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('Show your full name?')).toBeInTheDocument()
expect(mockUpdateUserById).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()

fireEvent.click(screen.getByText('Confirm'))
expect(onChange).toHaveBeenCalledWith(true)
await waitFor(() => expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', showFullName: true }))
})

it('cancelling leaves the setting off', () => {
const onChange = jest.fn()
render(<ShowNameToggle checked={false} onChange={onChange} />)

fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByText('Cancel'))

expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(mockUpdateUserById).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()
})

it('turning it off saves without a confirmation', async () => {
const onChange = jest.fn()
render(<ShowNameToggle checked onChange={onChange} />)

fireEvent.click(screen.getByRole('switch'))

expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
expect(onChange).toHaveBeenCalledWith(false)
await waitFor(() => expect(mockUpdateUserById).toHaveBeenCalledWith({ userId: 'u1', showFullName: false }))
})

it('reverts the optimistic value when the save resolves an error', async () => {
// updateUserById resolves { error } for a non-2xx or a network failure;
// it never rejects. Treating that as success left this screen claiming
// the legal name was hidden while the server still published it.
mockUpdateUserById.mockResolvedValueOnce({ error: 'nope' })
const onChange = jest.fn()
render(<ShowNameToggle checked onChange={onChange} />)

fireEvent.click(screen.getByRole('switch'))

await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(true))
expect(mockToastError).toHaveBeenCalled()
expect(mockFetchUser).not.toHaveBeenCalled()
})
})
15 changes: 12 additions & 3 deletions src/components/Profile/views/ProfileEdit.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export const ProfileEditView = () => {
// validation renders as the name field's own error instead.
const [errorMessage, setErrorMessage] = useState('')
const [nameError, setNameError] = useState('')
// Mirrors `showFullName` so the header above updates the moment the toggle
// flips, instead of waiting for the background user refetch to land.
const [showFullName, setShowFullName] = useState(user?.user.showFullName ?? false)

useEffect(() => {
setShowFullName(user?.user.showFullName ?? false)
}, [user?.user.showFullName])

// split the full name into name and surname
const splitName = useCallback((fullName: string) => {
Expand Down Expand Up @@ -172,14 +179,16 @@ export const ProfileEditView = () => {
}
}, [formData, user, fetchUser, router, isEmailSet, canEditName, t, tCommon])

const fullName = user?.user.fullName || user?.user?.username || ''
const username = user?.user.username || ''
// The header shows what the rest of the world sees: the full name only
// while it is public, the username otherwise.
const displayName = showFullName && user?.user.fullName ? user.user.fullName : username

return (
<div className="flex flex-col gap-8">
<NavHeader title={t('title')} onPrev={onBack} />

<ProfileHeader name={fullName} username={username} isVerified={isKycApproved} showShareButton={false} />
<ProfileHeader name={displayName} username={username} isVerified={isKycApproved} showShareButton={false} />

{/* two groups — who you are, then how we reach you. gap-6 (XL,
the section step) against gap-4 (L) inside a group, so the
Expand Down Expand Up @@ -252,7 +261,7 @@ export const ProfileEditView = () => {
position="single"
leading={<Icon name="eye" size={24} />}
title={tMenu('showMyFullName')}
trailing={<ShowNameToggle />}
trailing={<ShowNameToggle checked={showFullName} onChange={setShowFullName} />}
/>
)}
</div>
Expand Down
Loading
Loading