Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 30 additions & 13 deletions src/components/Badges/BadgeDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,36 @@

import Image from 'next/image'
import type { StaticImageData } from 'next/image'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import ActionModal from '../Global/ActionModal'
import ShareButton from '../Global/ShareButton'
import { getBadgeShareText } from './badge.utils'
import { useUserStore } from '@/redux/hooks'
import { BASE_URL } from '@/constants/general.consts'

type BadgeDetailModalProps = {
isOpen: boolean
onClose: () => void
code?: string
title: string
description: string
logo: string | StaticImageData
}

// the focal badge detail popup — large badge image + name + description.
// shared by the Your Badges list and the badge-unlock drawer so both
// surfaces show the exact same modal.
export const BadgeDetailModal = ({ isOpen, onClose, title, description, logo }: BadgeDetailModalProps) => {
const t = useTranslations('common')
// Shared by the badges list and the badge-unlock drawer. The primary action
// shares the badge, while the top-right close button remains the dismiss action.
export const BadgeDetailModal = ({ isOpen, onClose, code, title, description, logo }: BadgeDetailModalProps) => {
const t = useTranslations('badges')
const locale = useLocale()
const { user: authUser } = useUserStore()
const username = authUser?.user?.username
// the sharer's own public profile — showcases their badges + carries the join CTA
const profileUrl = username ? `${BASE_URL}/${username}` : BASE_URL

const shareText = getBadgeShareText(code, title, profileUrl, {
locale,
localizedFallback: t('shareText', { badge: title, link: profileUrl }),
})

return (
<ActionModal
Expand All @@ -28,13 +42,16 @@ export const BadgeDetailModal = ({ isOpen, onClose, title, description, logo }:
onClose={onClose}
title={title}
description={description}
ctas={[
{
text: t('gotIt'),
onClick: onClose,
shadowSize: '4',
},
]}
content={
<ShareButton
title=""
className="w-full"
onSuccess={onClose}
generateText={() => Promise.resolve(shareText)}
>
{t('shareAchievement')}
</ShareButton>
}
/>
)
}
4 changes: 3 additions & 1 deletion src/components/Badges/BadgeEarnToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'

const HOME_PATH = '/home'

type ModalBadge = { title: string; description: string; logo: string }
type ModalBadge = { code: string; title: string; description: string; logo: string }

export default function BadgeEarnToast() {
const t = useTranslations('badges')
Expand Down Expand Up @@ -66,6 +66,7 @@ export default function BadgeEarnToast() {
posthog.capture(ANALYTICS_EVENTS.BADGE_EARN_TOAST_TAPPED, { count })
if (count === 1) {
setModalBadge({
code: newest.code,
title: newestName,
description: newest.description || getPublicBadgeDescription(newest.code) || '',
logo: newestIcon,
Expand Down Expand Up @@ -118,6 +119,7 @@ export default function BadgeEarnToast() {
<BadgeDetailModal
isOpen
onClose={() => setModalBadge(null)}
code={modalBadge.code}
title={modalBadge.title}
description={modalBadge.description}
logo={modalBadge.logo}
Expand Down
16 changes: 13 additions & 3 deletions src/components/Badges/BadgeStatusDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Drawer, DrawerContent, DrawerTitle } from '@/components/Global/Drawer'
import Image from 'next/image'
import { useState } from 'react'
import { useFormatter, useTranslations } from 'next-intl'
import { useFormatter, useLocale, useTranslations } from 'next-intl'
import Card from '../Global/Card'
import { PaymentInfoRow } from '../Payment/PaymentInfoRow'
import ShareButton from '../Global/ShareButton'
import { BadgeDetailModal } from './BadgeDetailModal'
import { getBadgeDisplayName, getBadgeIcon } from './badge.utils'
import { getBadgeDisplayName, getBadgeIcon, getBadgeShareText } from './badge.utils'
import { BASE_URL } from '@/constants/general.consts'
import { useAuth } from '@/context/authContext'

Expand All @@ -25,6 +25,7 @@ export type BadgeStatusDrawerProps = {
// shows a drawer for a newly unlocked badge
export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerProps) => {
const t = useTranslations('badges')
const locale = useLocale()
const format = useFormatter()
const { user: authUser } = useAuth()
const [isDetailOpen, setIsDetailOpen] = useState(false)
Expand Down Expand Up @@ -92,7 +93,15 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP
<ShareButton
title=""
generateText={() =>
Promise.resolve(t('shareText', { badge: displayName, link: profileLink }))
Promise.resolve(
getBadgeShareText(badge.code, displayName, profileLink, {
locale,
localizedFallback: t('shareText', {
badge: displayName,
link: profileLink,
}),
})
)
}
>
{t('shareAchievement')}
Expand All @@ -104,6 +113,7 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP
<BadgeDetailModal
isOpen={isDetailOpen}
onClose={() => setIsDetailOpen(false)}
code={badge.code}
title={displayName}
description={badge.description || ''}
logo={getBadgeIcon(badge.code)}
Expand Down
89 changes: 89 additions & 0 deletions src/components/Badges/__tests__/BadgeDetailModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import type { ReactNode } from 'react'
import { BadgeDetailModal } from '../BadgeDetailModal'
import { BASE_URL } from '@/constants/general.consts'
import en from '@/i18n/app/messages/en.json'
import ptBR from '@/i18n/app/messages/pt-BR.json'

type MockShareButtonProps = {
children?: ReactNode
generateText: () => Promise<string>
onSuccess?: () => void
}

const mockShareButton = jest.fn<void, [MockShareButtonProps]>()

jest.mock('@/components/Global/ShareButton', () => ({
__esModule: true,
default: (props: MockShareButtonProps) => {
mockShareButton(props)
return (
<button type="button" onClick={props.onSuccess}>
{props.children}
</button>
)
},
}))

jest.mock('@/components/Global/ActionModal', () => ({
__esModule: true,
default: ({ visible, content }: { visible: boolean; content?: ReactNode }) =>
visible ? <div>{content}</div> : null,
}))

jest.mock('next/image', () => ({
__esModule: true,
default: () => null,
}))

jest.mock('@/redux/hooks', () => ({
useUserStore: () => ({ user: { user: { username: 'satoshi' } } }),
}))

const onClose = jest.fn()

function renderModal(locale: 'en' | 'pt-BR') {
const messages = locale === 'en' ? en : ptBR

return render(
<NextIntlClientProvider locale={locale} messages={messages}>
<BadgeDetailModal
isOpen
onClose={onClose}
code="CARD_FIRST_SWIPE"
title="First Swipe"
description="First swipe badge"
logo="/badges/happy_card.svg"
/>
</NextIntlClientProvider>
)
}

beforeEach(() => {
jest.clearAllMocks()
})

describe('BadgeDetailModal', () => {
it('shares bespoke English copy with the signed-in user profile and closes on success', async () => {
renderModal('en')

expect(screen.getByRole('button', { name: en.badges.shareAchievement })).toBeInTheDocument()
const shareProps = mockShareButton.mock.calls[0][0]
await expect(shareProps.generateText()).resolves.toContain('Just put my Peanut card to work')
await expect(shareProps.generateText()).resolves.toContain(`${BASE_URL}/satoshi`)

screen.getByRole('button', { name: en.badges.shareAchievement }).click()
expect(onClose).toHaveBeenCalledTimes(1)
})

it('keeps the translated generic share copy outside English', async () => {
renderModal('pt-BR')

expect(screen.getByRole('button', { name: ptBR.badges.shareAchievement })).toBeInTheDocument()
const text = await mockShareButton.mock.calls[0][0].generateText()
expect(text).toContain('Ganhei o selo First Swipe no Peanut!')
expect(text).toContain(`${BASE_URL}/satoshi`)
expect(text).not.toContain('Just put my Peanut card to work')
})
})
9 changes: 7 additions & 2 deletions src/components/Badges/__tests__/BadgeEarnToast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ jest.mock('@/components/Badges/useBadgeEarnToast', () => ({

// Minimal stub: surface the title so we can assert the detail modal opened.
jest.mock('@/components/Badges/BadgeDetailModal', () => ({
BadgeDetailModal: ({ isOpen, title }: { isOpen: boolean; title: string }) =>
isOpen ? <div data-testid="badge-detail-modal">{title}</div> : null,
BadgeDetailModal: ({ isOpen, title, code }: { isOpen: boolean; title: string; code?: string }) =>
isOpen ? (
<div data-testid="badge-detail-modal" data-code={code}>
{title}
</div>
) : null,
}))

import posthog from 'posthog-js'
Expand Down Expand Up @@ -87,6 +91,7 @@ describe('BadgeEarnToast', () => {
expect(mockDismissToast).toHaveBeenCalledWith('badge-earn:PRODUCT_HUNT')
expect(captureMock).toHaveBeenCalledWith('badge_earn_toast_tapped', { count: 1 })
expect(screen.getByTestId('badge-detail-modal')).toHaveTextContent('Product Hunt')
expect(screen.getByTestId('badge-detail-modal')).toHaveAttribute('data-code', 'PRODUCT_HUNT')
expect(mockRouterPush).not.toHaveBeenCalled()
})

Expand Down
57 changes: 56 additions & 1 deletion src/components/Badges/__tests__/badge.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BADGES, getBadgeIcon } from '../badge.utils'
import { BADGES, getBadgeIcon, getBadgeShareText } from '../badge.utils'

describe('getBadgeIcon', () => {
it('returns the badge path for known codes', () => {
Expand All @@ -14,3 +14,58 @@ describe('getBadgeIcon', () => {
expect(getBadgeIcon(undefined)).toBe(getBadgeIcon('NOT_A_REAL_BADGE'))
})
})

describe('getBadgeShareText', () => {
const url = 'https://peanut.me/satoshi'

it('uses the badge-specific brag line for a known code (not the generic fallback) and appends the profile url', () => {
// Copy-agnostic on purpose: assert a mapped code yields something OTHER than
// the generic fallback, so editing a line never breaks this test.
const mapped = getBadgeShareText('CARD_FIRST_SWIPE', 'First Swipe', url)
const fallback = getBadgeShareText('___UNMAPPED___', 'First Swipe', url)
expect(mapped).not.toBe(fallback)
expect(mapped).toContain(url)
// first-person voice — the sharer is bragging about themselves
expect(mapped).toMatch(/\b(I|my)\b/i)
})

it('uses bespoke copy for the runtime English locale options path', () => {
const text = getBadgeShareText('CARD_FIRST_SWIPE', 'First Swipe', url, {
locale: 'en',
localizedFallback: 'localized fallback sentinel',
})

expect(text).not.toContain('localized fallback sentinel')
expect(text).toContain(url)
})

it('includes bespoke copy for the MANICERO badge added after the original PR', () => {
const mapped = getBadgeShareText('MANICERO', 'Manicero', url)
const fallback = getBadgeShareText('___UNMAPPED___', 'Manicero', url)

expect(mapped).not.toBe(fallback)
})

it('falls back to a generic brag (with display name) for unknown / parked codes', () => {
const text = getBadgeShareText('NOT_A_REAL_BADGE', 'Mystery Badge', url)
expect(text).toContain('Mystery Badge')
expect(text).toContain(url)
})

it('still produces shareable text when the code is undefined', () => {
const text = getBadgeShareText(undefined, 'Some Badge', url)
expect(text).toContain('Some Badge')
expect(text).toContain(url)
})

it('keeps the localized generic copy outside English', () => {
const localizedFallback = `Ganhei o selo First Swipe no Peanut!\n\n${url}`

expect(
getBadgeShareText('CARD_FIRST_SWIPE', 'First Swipe', url, {
locale: 'pt-BR',
localizedFallback,
})
).toBe(localizedFallback)
})
})
Loading
Loading