From b513f645af191363d121d3f00ac9e2602d1ff955 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 10:53:59 +0000 Subject: [PATCH 1/2] feat: refresh badge sharing for current dev Rebuild the stale badge-sharing change on the current dev tree, keep bespoke English badge copy in the canonical badge catalog, preserve localized fallbacks, and keep failed desktop shares open. --- src/components/Badges/BadgeDetailModal.tsx | 43 +++++--- src/components/Badges/BadgeEarnToast.tsx | 4 +- src/components/Badges/BadgeStatusDrawer.tsx | 16 ++- .../__tests__/BadgeDetailModal.test.tsx | 89 +++++++++++++++++ .../Badges/__tests__/BadgeEarnToast.test.tsx | 9 +- .../Badges/__tests__/badge.utils.test.ts | 57 ++++++++++- src/components/Badges/badge.utils.ts | 63 ++++++++++++ src/components/Badges/index.tsx | 4 +- .../__tests__/ShareButton.test.tsx | 97 +++++++++++++++++++ src/components/Global/ShareButton/index.tsx | 7 ++ 10 files changed, 368 insertions(+), 21 deletions(-) create mode 100644 src/components/Badges/__tests__/BadgeDetailModal.test.tsx create mode 100644 src/components/Global/ShareButton/__tests__/ShareButton.test.tsx diff --git a/src/components/Badges/BadgeDetailModal.tsx b/src/components/Badges/BadgeDetailModal.tsx index 5f81cd1179..fae5044a20 100644 --- a/src/components/Badges/BadgeDetailModal.tsx +++ b/src/components/Badges/BadgeDetailModal.tsx @@ -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 ( Promise.resolve(shareText)} + > + {t('shareAchievement')} + + } /> ) } diff --git a/src/components/Badges/BadgeEarnToast.tsx b/src/components/Badges/BadgeEarnToast.tsx index 33a436af37..2000f84142 100644 --- a/src/components/Badges/BadgeEarnToast.tsx +++ b/src/components/Badges/BadgeEarnToast.tsx @@ -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') @@ -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, @@ -118,6 +119,7 @@ export default function BadgeEarnToast() { setModalBadge(null)} + code={modalBadge.code} title={modalBadge.title} description={modalBadge.description} logo={modalBadge.logo} diff --git a/src/components/Badges/BadgeStatusDrawer.tsx b/src/components/Badges/BadgeStatusDrawer.tsx index abd33dcaa3..1b52a38eeb 100644 --- a/src/components/Badges/BadgeStatusDrawer.tsx +++ b/src/components/Badges/BadgeStatusDrawer.tsx @@ -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' @@ -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) @@ -92,7 +93,15 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP - 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')} @@ -104,6 +113,7 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP setIsDetailOpen(false)} + code={badge.code} title={displayName} description={badge.description || ''} logo={getBadgeIcon(badge.code)} diff --git a/src/components/Badges/__tests__/BadgeDetailModal.test.tsx b/src/components/Badges/__tests__/BadgeDetailModal.test.tsx new file mode 100644 index 0000000000..512c697292 --- /dev/null +++ b/src/components/Badges/__tests__/BadgeDetailModal.test.tsx @@ -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 + onSuccess?: () => void +} + +const mockShareButton = jest.fn() + +jest.mock('@/components/Global/ShareButton', () => ({ + __esModule: true, + default: (props: MockShareButtonProps) => { + mockShareButton(props) + return ( + + ) + }, +})) + +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: ({ visible, content }: { visible: boolean; content?: ReactNode }) => + visible ?
{content}
: 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( + + + + ) +} + +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') + }) +}) diff --git a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx index ff556bf1c1..6fa20186cb 100644 --- a/src/components/Badges/__tests__/BadgeEarnToast.test.tsx +++ b/src/components/Badges/__tests__/BadgeEarnToast.test.tsx @@ -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 ?
{title}
: null, + BadgeDetailModal: ({ isOpen, title, code }: { isOpen: boolean; title: string; code?: string }) => + isOpen ? ( +
+ {title} +
+ ) : null, })) import posthog from 'posthog-js' @@ -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() }) diff --git a/src/components/Badges/__tests__/badge.utils.test.ts b/src/components/Badges/__tests__/badge.utils.test.ts index a0e6c3f357..3e3b178ddb 100644 --- a/src/components/Badges/__tests__/badge.utils.test.ts +++ b/src/components/Badges/__tests__/badge.utils.test.ts @@ -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', () => { @@ -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) + }) +}) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 0e1f50b165..22d529315c 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -15,52 +15,65 @@ export type BadgeMeta = { path: string description?: string displayName?: string + // English-only bespoke copy. Missing values use the generic localized message. + shareLine?: string } export const BADGES: Record = { BETA_TESTER: { path: '/badges/beta_tester.svg', description: `They're in the lab with us. Early enough to be part of the experiment.`, + shareLine: "I've been in the Peanut lab since the early experiments. Officially a beta tester πŸ§ͺ", }, DEVCONNECT_BA_2025: { path: '/badges/devconnect_2025.svg', description: 'Buenos Aires, baby. They came, they claimed, they ate the steak.', + shareLine: 'Buenos Aires βœ… Peanut badge βœ… A perfect trip', }, PRODUCT_HUNT: { path: '/badges/product_hunt.svg', description: 'Hope Dealer. Their upvote felt like a VC term sheet!', + shareLine: 'I upvoted Peanut on Product Hunt before it was cool. Hope dealer, certified πŸš€', }, OG_2025_10_12: { path: '/badges/og_v1.svg', description: 'A real OG. They were with Peanut before it was cool.', + shareLine: 'I was here before it was cool. Certified Peanut OG πŸ₯œ', }, MOST_RESTAURANTS_DEVCON: { path: '/badges/foodie.svg', description: 'Hit more restaurants than the Michelin guide. Touched every menu in BA.', + shareLine: 'I hit more restaurants at Devconnect than the Michelin guide. Paid for all of them with Peanut 🍽️', }, BIG_SPENDER_5K: { path: '/badges/big_spender.svg', description: `They didn't come to Devconnect to network. They came to spend.`, + shareLine: "I didn't come to Devconnect to network. I came to spend. $5K later… πŸ’Έ", }, MOST_PAYMENTS_DEVCON: { path: '/badges/most_payments.svg', description: `Money Machine β€” they move money like it's light work. Most payments made!`, + shareLine: "I move money like it's light work. Most payments at Devconnect β€” certified money machine ⚑", }, MOST_INVITES: { path: '/badges/most_invites.svg', description: 'Onboarded more users than Coinbase ads!', + shareLine: "I onboarded more people to Peanut than Coinbase's ad budget did πŸ“ˆ", }, BIGGEST_REQUEST_POT: { path: '/badges/biggest_request_pot.svg', description: 'High Roller or Master Beggar? They created the pot with the highest number of contributors.', + shareLine: 'High roller or master beggar? Either way I ran the biggest pot on Peanut πŸ«—', }, SEEDLING_DEVCONNECT_BA_2025: { path: '/badges/seedlings_devconnect.svg', description: `You shill Peanut so we don't have to. Honorary squirrel.`, + shareLine: "I shill Peanut so they don't have to. Honorary squirrel 🐿️", }, ARBIVERSE_DEVCONNECT_BA_2025: { path: '/badges/arbiverse_devconnect.svg', description: 'They found the Arbiverse booth. We found them. Mutual onboarding achieved.', + shareLine: 'I went looking for Arbitrum and Peanut found me πŸ”΅', }, // Rebranded from "Card Pioneer" to "Founding Pioneer". Backend still emits the // CARD_PIONEER code (it also gates grandfathered card access + rewards), so we @@ -70,20 +83,24 @@ export const BADGES: Record = { path: '/badges/founding_pioneer.svg', description: 'You built Peanut before it had a launch.', displayName: 'Founding Pioneer', + shareLine: 'I was building Peanut before it had a launch. Founding Pioneer πŸ› οΈ', }, // New invite-activated community badge for the early crew (invite code "founding"). FOUNDING_PIONEER: { path: '/badges/founding_pioneer.svg', description: 'You built Peanut before it had a launch.', displayName: 'Founding Pioneer', + shareLine: 'I was building Peanut before it had a launch. Founding Pioneer πŸ› οΈ', }, FOUNDER_HOUSE: { path: '/badges/founder_house.svg', description: 'Built IRL at Founder Haus. On-chain energy, off-chain handshakes.', + shareLine: 'On-chain energy, off-chain handshakes. Built it IRL at Founder Haus 🀝', }, BUG_WHISPERER: { path: '/badges/bug_whisperer.svg', description: 'They found a real bug, reported it, and stayed. We owe them a beer.', + shareLine: 'I found a real bug in Peanut, reported it, and stuck around. Someone owes me a beer πŸ›πŸΊ', }, // Legacy code from the original "SUPPORT_SURVIVOR" badge. Backend still emits this code; // we render the new beetle asset + the renamed copy. Drop once backend migrates to BUG_WHISPERER. @@ -91,53 +108,65 @@ export const BADGES: Record = { path: '/badges/bug_whisperer.svg', description: 'They found a real bug, reported it, and stayed. We owe them a beer.', displayName: 'Bug Whisperer', + shareLine: 'I found a real bug in Peanut, reported it, and stuck around. Someone owes me a beer πŸ›πŸΊ', }, // ── card-launch badges (awarded server-side: waitlist signup + card-spend milestones) ── SHHHHH: { path: '/badges/shhhhh.svg', description: 'They know the secret.', + shareLine: "I know the secret. That's all I'm allowed to say 🀫", }, NOT_SO_SHHHH: { path: '/badges/not_so_shhhh.svg', description: "You couldn't keep it quiet β€” and you got paid for it.", + shareLine: "I couldn't keep the secret… and Peanut paid me for it πŸ€«πŸ’Έ", }, CARD_FIRST_SWIPE: { path: '/badges/happy_card.svg', description: 'First swipe. They put their card to work.', + shareLine: 'Just put my Peanut card to work for the first time. They grow up so fast πŸ’³', }, CARD_SPENT_1K: { path: '/badges/money_stack.svg', description: '$1K swiped. They put their money where their card is.', + shareLine: "Crossed $1K on the Peanut card. It's earning its keep πŸ’³", }, // ── growth Β· invite ladder (awarded by invites_accepted count) ────────── FIRST_INVITE: { path: '/badges/first_invite.svg', description: 'Brought a friend to the table. One down, a whole network to go.', + shareLine: 'Brought my first friend onto Peanut. One down, the whole group chat to go πŸ‘‹', }, SECOND_INVITE: { path: '/badges/second_invite.svg', description: `Word's getting around β€” two friends in and rising.`, + shareLine: "Two friends on Peanut and counting. Word's getting around πŸ“£", }, THIRD_INVITE: { path: '/badges/third_invite.svg', description: 'Three friends, no misses. Tip your hat.', + shareLine: 'Three friends on Peanut, zero misses. Tip your hat 🎩', }, MINI_INFLUENCER: { path: '/badges/mini_influencer.svg', description: 'Built a little fan club, one invite at a time.', + shareLine: 'Built a little Peanut fan club, one invite at a time 🌟', }, INFLUENCER_25: { path: '/badges/influencer_25.svg', description: 'Twenty-five strong. The likes keep rolling in.', displayName: 'Influencer', + shareLine: 'Twenty-five friends on Peanut. The likes keep rolling in 🌟', }, MEGA_INFLUENCER: { path: '/badges/invites_100.svg', description: `A hundred friends in. They're kind of a big deal now.`, + shareLine: "A hundred friends on Peanut. I'm kind of a big deal now 😎", }, DUNBAR: { path: '/badges/dunbar.svg', description: `150 β€” more people than you can remember. They hit Dunbar's number.`, + shareLine: "150 invites β€” more people than I can even remember. I hit Dunbar's number 🧠", }, // ── growth Β· invite-streak ladder β€” DEFERRED ──────────────────────────── // A streak is ephemeral live state that resets on a miss, not a permanent @@ -155,37 +184,44 @@ export const BADGES: Record = { GIGA_YAPPER: { path: '/badges/giga_yapper.svg', description: `Giga loud. They don't mention Peanut, they broadcast it.`, + shareLine: "I don't mention Peanut, I broadcast it. Giga Yapper πŸ“’", }, // ── usage Β· rewards-earned ladder ─────────────────────────────────────── FIRST_CRUMB: { path: '/badges/first_crumb.svg', description: 'First dollar earned. Proof it pays.', displayName: 'First Dollar', + shareLine: 'Earned my first dollar on Peanut. Proof that it pays πŸͺ™', }, DOUBLE_DIGITS: { path: '/badges/double_digits.svg', description: 'Crossed into double digits. Real money now.', + shareLine: 'Crossed into double digits on Peanut. Real money now πŸ’°', }, // ── insider ───────────────────────────────────────────────────────────── VERIFIED: { path: '/badges/verified.svg', description: 'ID checked, identity confirmed. Officially verified.', + shareLine: 'ID checked, identity confirmed. Officially verified on Peanut βœ…', }, CARD_CLOSED_BETA: { path: '/badges/card_closed_beta.svg', description: 'IYKYK. They were testing the card before you knew it existed.', displayName: 'Closed Beta', + shareLine: 'I was testing the Peanut card before you knew it existed. IYKYK πŸ€«πŸ’³', }, CARD_ALPHA: { path: '/badges/card_alpha.svg', description: 'You tested the Card while it was still held together with tape and hope.', displayName: 'Closed Alpha Tester', + shareLine: 'I tested the Peanut card while it was still held together with tape and hope πŸ©ΉπŸ’³', }, // ── community (link-granted) ──────────────────────────────────────────── ARBITRUM: { path: '/badges/arbitrum.svg', description: 'Found on Arbitrum β€” mutual onboarding achieved.', displayName: 'Arbitrum Native', + shareLine: 'Peanut Γ— Arbitrum. Fast chains, faster money πŸ”΅', }, // Event badges β€” assets shipped to main via the May 29 hotfix but the catalog // entries were dropped when the parallel maps collapsed into this single BADGES @@ -204,39 +240,47 @@ export const BADGES: Record = { path: '/badges/manicero.svg', description: 'Small manΓ­. Big energy. Manicero.', displayName: 'Manicero', + shareLine: 'Small manΓ­, big energy. I earned the Manicero badge πŸ₯œ', }, TOUCHED_GRASS: { path: '/badges/touched_grass.svg', description: 'You logged off and touched real grass with Peanut.', + shareLine: 'Touched grass badge. Proof that I do go outside 🌱', }, OFFRAMP_USER: { path: '/badges/offramp_user.png', description: 'You migrated to Peanut. We welcomed you.', + shareLine: 'I migrated to Peanut. New home, same money, one shiny badge πŸ₯œ', }, PSYOPS_DIVISION: { path: '/badges/psyops_division.svg', description: 'Enlisted in the Psyops Division. Welcome to the influence game.', displayName: 'Psyops Division', + shareLine: 'Enlisted in the Peanut Psyops Division. The influence game is real 🧠', }, EVENT_ALUMNI: { path: '/badges/event_alumni.svg', description: 'Old school. You were in the room before most.', + shareLine: 'Old school. I was in the room before most of you 🎟️', }, ETHFLORIPA_HUB: { path: '/badges/ethfloripa_hub.svg', description: 'Ilha da Magia, baby. Coconuts and consensus.', displayName: 'Ethereum Hub Floripa', + shareLine: 'Ilha da Magia, baby. Coconuts and consensus πŸ₯₯', }, IRL_NOMADS: { path: '/badges/irl_nomads.svg', description: 'No fixed address, just good coffee and better wifi. Certified Nomad.', displayName: 'Nomad Mode', + shareLine: 'Nomad mode on. My office is wherever the wifi is β˜•', }, // Skip Pass β€” friends-of-Peanut who bypassed the waitlist via /invite?campaign=skip. // Awarded by the backend /badge/award endpoint, which also flips hasAppAccess. WAITLIST_SKIP: { path: '/badges/skip_pass.svg', description: 'They skipped the waitlist. A friend handed them the key and they walked right in.', + shareLine: "Got the skip pass. It's not what you know, it's who invites you πŸ”‘", }, } @@ -260,3 +304,22 @@ export function getPublicBadgeDescription(code?: string): string | null { export function getBadgeDisplayName(code: string | undefined, fallback: string): string { return (code && BADGES[code]?.displayName) || fallback } + +// Builds the text pre-filled into the native share sheet (Web Share API) / copied to +// clipboard when a badge is shared from the detail modal. Composition: +// \n\nJoin me on Peanut πŸ‘‰ +// profileUrl is the sharer's own public profile β€” it showcases their badges and +// carries the join CTA. Other locales keep their existing translated generic copy +// until bespoke translations ship in a separate content change. CERTIFIED_YAPPER, +// TOKEN_NATION_SP_2026, and FESTA_JUNINA_2026 intentionally use the generic copy. +export function getBadgeShareText( + code: string | undefined, + displayName: string, + profileUrl: string, + localized?: { locale: string; localizedFallback: string } +): string { + if (localized && localized.locale !== 'en') return localized.localizedFallback + + const brag = (code && BADGES[code]?.shareLine) || `I just unlocked the "${displayName}" badge on Peanut πŸ₯œ` + return `${brag}\n\nJoin me on Peanut πŸ‘‰ ${profileUrl}` +} diff --git a/src/components/Badges/index.tsx b/src/components/Badges/index.tsx index aecdb7ee4b..5dc827f8e2 100644 --- a/src/components/Badges/index.tsx +++ b/src/components/Badges/index.tsx @@ -15,7 +15,7 @@ import { useUserStore } from '@/redux/hooks' import { ActionListCard } from '../ActionListCard' import { useAuth } from '@/context/authContext' -type BadgeView = { title: string; description: string; logo: string | StaticImageData } +type BadgeView = { code: string; title: string; description: string; logo: string | StaticImageData } export const Badges = () => { const t = useTranslations('badges') @@ -35,6 +35,7 @@ export const Badges = () => { // get badges from user object and map to card fields const raw = authUser?.user?.badges || [] return raw.map((b) => ({ + code: b.code, title: getBadgeDisplayName(b.code, b.name), description: b.description || '', logo: getBadgeIcon(b.code), @@ -99,6 +100,7 @@ export const Badges = () => { setIsBadgeModalOpen(false) setSelectedBadge(null) }} + code={selectedBadge.code} title={selectedBadge.title} description={selectedBadge.description} logo={selectedBadge.logo} diff --git a/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx b/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx new file mode 100644 index 0000000000..9da2409c59 --- /dev/null +++ b/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { ComponentProps } from 'react' +import ShareButton from '../index' +import { renderWithIntl } from '@/test-utils/intl' + +const mockToastInfo = jest.fn() +const mockToastError = jest.fn() + +jest.mock('@/components/0_Bruddle/Toast', () => ({ + useToast: () => ({ info: mockToastInfo, error: mockToastError }), +})) + +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children, className, onClick, type }: ComponentProps<'button'>) => ( + + ), +})) + +jest.mock('@/components/Global/Icons/Icon', () => ({ + Icon: () => null, +})) + +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) + +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') +const originalShare = Object.getOwnPropertyDescriptor(navigator, 'share') +const originalSecureContext = Object.getOwnPropertyDescriptor(window, 'isSecureContext') +let consoleErrorSpy: jest.SpyInstance + +function restoreProperty(target: object, key: string, descriptor?: PropertyDescriptor) { + if (descriptor) Object.defineProperty(target, key, descriptor) + else delete (target as Record)[key] +} + +beforeEach(() => { + jest.clearAllMocks() + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true }) + Object.defineProperty(navigator, 'share', { configurable: true, value: undefined }) +}) + +afterEach(() => { + consoleErrorSpy.mockRestore() +}) + +afterAll(() => { + restoreProperty(navigator, 'clipboard', originalClipboard) + restoreProperty(navigator, 'share', originalShare) + restoreProperty(window, 'isSecureContext', originalSecureContext) +}) + +describe('ShareButton', () => { + it('does not report success when desktop clipboard copying fails', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: jest.fn().mockRejectedValue(new Error('denied')) }, + }) + const onSuccess = jest.fn() + const onError = jest.fn() + + renderWithIntl( + Promise.resolve('Badge share text')} + onSuccess={onSuccess} + onError={onError} + > + Share badge + + ) + fireEvent.click(screen.getByRole('button', { name: 'Share badge' })) + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)) + expect(onSuccess).not.toHaveBeenCalled() + expect(mockToastError).toHaveBeenCalledTimes(1) + }) + + it('reports success after desktop clipboard copying succeeds', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: jest.fn().mockResolvedValue(undefined) }, + }) + const onSuccess = jest.fn() + + renderWithIntl( + Promise.resolve('Badge share text')} onSuccess={onSuccess}> + Share badge + + ) + fireEvent.click(screen.getByRole('button', { name: 'Share badge' })) + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)) + expect(mockToastInfo).toHaveBeenCalledTimes(1) + expect(mockToastError).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Global/ShareButton/index.tsx b/src/components/Global/ShareButton/index.tsx index d44b9665df..cc869d57b9 100644 --- a/src/components/Global/ShareButton/index.tsx +++ b/src/components/Global/ShareButton/index.tsx @@ -90,6 +90,13 @@ const ShareButton = ({ await navigator.share(shareData) } + if (!navigator.share && !copied) { + const error = new Error('Clipboard copy failed and the Web Share API is unavailable') + toast.error(t('shareButton.sharingFailed')) + onError?.(error) + return + } + onSuccess?.() } catch (error) { const err = error instanceof Error ? error : new Error(String(error)) From 0df446f21904dc27df2e4c432a5258e3b3c2e8de Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 11:01:01 +0000 Subject: [PATCH 2/2] fix: respect legacy clipboard failures Treat execCommand returning false as a failed copy, clean up the temporary textarea in all paths, and keep callers open when no share mechanism succeeds. --- .../__tests__/ShareButton.test.tsx | 28 +++++++++++++++++++ src/components/Global/ShareButton/index.tsx | 10 ++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx b/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx index 9da2409c59..f1222525f4 100644 --- a/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx +++ b/src/components/Global/ShareButton/__tests__/ShareButton.test.tsx @@ -27,6 +27,7 @@ jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') const originalShare = Object.getOwnPropertyDescriptor(navigator, 'share') const originalSecureContext = Object.getOwnPropertyDescriptor(window, 'isSecureContext') +const originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand') let consoleErrorSpy: jest.SpyInstance function restoreProperty(target: object, key: string, descriptor?: PropertyDescriptor) { @@ -49,6 +50,7 @@ afterAll(() => { restoreProperty(navigator, 'clipboard', originalClipboard) restoreProperty(navigator, 'share', originalShare) restoreProperty(window, 'isSecureContext', originalSecureContext) + restoreProperty(document, 'execCommand', originalExecCommand) }) describe('ShareButton', () => { @@ -76,6 +78,32 @@ describe('ShareButton', () => { expect(mockToastError).toHaveBeenCalledTimes(1) }) + it('does not report success when the legacy copy fallback returns false', async () => { + Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: jest.fn().mockReturnValue(false), + }) + const onSuccess = jest.fn() + const onError = jest.fn() + + renderWithIntl( + Promise.resolve('Badge share text')} + onSuccess={onSuccess} + onError={onError} + > + Share badge + + ) + fireEvent.click(screen.getByRole('button', { name: 'Share badge' })) + + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)) + expect(onSuccess).not.toHaveBeenCalled() + expect(mockToastError).toHaveBeenCalledTimes(1) + expect(document.querySelector('textarea')).toBeNull() + }) + it('reports success after desktop clipboard copying succeeds', async () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, diff --git a/src/components/Global/ShareButton/index.tsx b/src/components/Global/ShareButton/index.tsx index cc869d57b9..abb85ee782 100644 --- a/src/components/Global/ShareButton/index.tsx +++ b/src/components/Global/ShareButton/index.tsx @@ -44,13 +44,15 @@ const ShareButton = ({ const toast = useToast() const copyTextToClipboardWithFallback = async (text: string) => { + let textArea: HTMLTextAreaElement | undefined + try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) return true } else { // Fallback for older browsers - const textArea = document.createElement('textarea') + textArea = document.createElement('textarea') textArea.value = text textArea.style.position = 'fixed' textArea.style.left = '-999999px' @@ -58,13 +60,13 @@ const ShareButton = ({ document.body.appendChild(textArea) textArea.focus() textArea.select() - document.execCommand('copy') - document.body.removeChild(textArea) - return true + return document.execCommand('copy') } } catch (err) { console.error('Failed to copy: ', err) return false + } finally { + textArea?.remove() } }