diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 0e37a72b5..db6893608 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -500,9 +500,20 @@ const CardPage: FC = () => { badgeCode={skipCode} username={user?.user?.username ?? undefined} badges={allBadges} - onContinue={() => { + onContinue={async () => { + // localStorage drives the in-flow re-celebration gate; + // setState transitions the screen immediately. markSkipCelebrationSeen() setSkipCelebrationSeen(true) + // Persist the write-once server marker so the home + // activity-feed share-asset row knows the user went + // through the flow. Best-effort; await before refetch so + // cardInfo.skipCelebrationSeen comes back true. + try { + await cardApi.markCelebrationSeen() + } catch (err) { + console.error('[card] markCelebrationSeen failed:', err) + } invalidateOverview() void refetchCardInfo() }} diff --git a/src/components/Card/__tests__/cardUnlock.types.test.ts b/src/components/Card/__tests__/cardUnlock.types.test.ts index 211f4038f..3482e458f 100644 --- a/src/components/Card/__tests__/cardUnlock.types.test.ts +++ b/src/components/Card/__tests__/cardUnlock.types.test.ts @@ -3,13 +3,13 @@ import { deriveCardUnlockEntry, isCardUnlockHistoryItem } from '../cardUnlock.ty describe('deriveCardUnlockEntry', () => { const earnedAt = '2025-10-12T00:00:00.000Z' - it('returns null for an access-only user with NO issued card (the gabby/dragon bug)', () => { - // Holds the OG skip badge → hasCardAccess, but never got a card. - // Pre-fix this surfaced a "You skipped the line" share asset to - // ~33% of users. Gating on hasIssuedCard kills it. + it('returns null for an access-only user who never went through the flow (the gabby/dragon bug)', () => { + // Holds the OG skip badge → hasCardAccess, but never entered the flow + // (no celebration seen, no card). Pre-fix this surfaced a "You skipped + // the line" share asset to ~33% of users. wentThroughFlow=false kills it. expect( deriveCardUnlockEntry({ - hasIssuedCard: false, + wentThroughFlow: false, hasCardAccess: true, cardAccessGrantedAt: null, skipBadges: ['OG_2025_10_12'], @@ -18,10 +18,10 @@ describe('deriveCardUnlockEntry', () => { ).toBeNull() }) - it('returns null for an admin-granted user with no issued card', () => { + it('returns null for an admin-granted user who never went through the flow', () => { expect( deriveCardUnlockEntry({ - hasIssuedCard: false, + wentThroughFlow: false, hasCardAccess: true, cardAccessGrantedAt: '2026-06-01T00:00:00.000Z', skipBadges: [], @@ -29,9 +29,9 @@ describe('deriveCardUnlockEntry', () => { ).toBeNull() }) - it('returns a badge entry once the user actually has a card', () => { + it('returns a badge entry once the user went through the flow (saw celebration or holds a card)', () => { const entry = deriveCardUnlockEntry({ - hasIssuedCard: true, + wentThroughFlow: true, hasCardAccess: true, cardAccessGrantedAt: null, skipBadges: ['OG_2025_10_12'], @@ -44,10 +44,10 @@ describe('deriveCardUnlockEntry', () => { expect(isCardUnlockHistoryItem(entry)).toBe(true) }) - it('returns an admin entry (no badge) when granted + card issued', () => { + it('returns an admin entry (no badge) when granted + went through the flow', () => { const grantedAt = '2026-06-01T00:00:00.000Z' const entry = deriveCardUnlockEntry({ - hasIssuedCard: true, + wentThroughFlow: true, hasCardAccess: true, cardAccessGrantedAt: grantedAt, skipBadges: [], @@ -56,12 +56,12 @@ describe('deriveCardUnlockEntry', () => { expect(entry?.timestamp).toBe(grantedAt) }) - it('returns null when a card exists but no derivable timestamp', () => { - // hasIssuedCard but no grant + no skip-badge earnedAt → nothing to + it('returns null when through the flow but no derivable timestamp', () => { + // wentThroughFlow but no grant + no skip-badge earnedAt → nothing to // anchor the row to; don't fabricate one. expect( deriveCardUnlockEntry({ - hasIssuedCard: true, + wentThroughFlow: true, hasCardAccess: true, cardAccessGrantedAt: null, skipBadges: [], diff --git a/src/components/Card/cardUnlock.types.ts b/src/components/Card/cardUnlock.types.ts index 7ac71ed3d..c570f02b6 100644 --- a/src/components/Card/cardUnlock.types.ts +++ b/src/components/Card/cardUnlock.types.ts @@ -4,8 +4,9 @@ * can re-open the share asset they earned on the celebration moment. * * No DB table needed — derived client-side. Only surfaces once the user - * has an issued card (see deriveCardUnlockEntry); the timestamp comes from - * their cardAccessGrantedAt or earliest skip-badge earnedAt. + * went through the flow — saw the celebration or holds a card (see + * deriveCardUnlockEntry); the timestamp comes from their cardAccessGrantedAt + * or earliest skip-badge earnedAt. */ export type CardUnlockVia = 'badge' | 'admin' | 'public-launch' @@ -31,29 +32,35 @@ export const isCardUnlockHistoryItem = (entry: unknown): entry is CardUnlockHist ) } -/** Returns null unless the user has ACTUALLY been issued a card. Card +/** Returns null unless the user actually WENT THROUGH the card flow. Card * *access* (a skip badge or an admin grant) is NOT enough — it only means - * the user is allowed a card, not that they ever entered the flow or got - * one. Gating on access alone surfaced this share-asset row + "I got my - * Peanut card" image to ~33% of users (every OG / Devconnect / Arbiverse / - * Pioneer badge holder), the vast majority of whom never received a card. + * the user is allowed a card, not that they ever entered the flow. Gating + * on access alone surfaced this share-asset row + "I got my Peanut card" + * image to ~33% of users (every OG / Devconnect / Arbiverse / Pioneer + * badge holder), the vast majority of whom never touched the card flow. * - * Once gated on `hasIssuedCard`, picks the best available timestamp: + * `wentThroughFlow` is the caller's combination of the two server-truth + * signals for "went through": saw the celebration + * (`cardInfo.skipCelebrationSeen`) OR holds an issued card (a cardholder + * definitionally went through, and this keeps pre-wiring cardholders). + * + * Once gated, picks the best available timestamp: * 1. Explicit `cardAccessGrantedAt` (admin grant / waitlist release). * 2. Earliest skip-badge `earnedAt` (badge-driven access — user has * been "in" since they earned the badge, even if the BE never * stamped a separate grant timestamp). * If neither is present, returns null rather than fabricating one. */ export function deriveCardUnlockEntry(args: { - /** True once the user has at least one Rain card row (the only - * server-verifiable signal that they got through the card flow). */ - hasIssuedCard: boolean + /** True once the user went through the card flow — saw the celebration + * OR holds an issued card. The only server-verifiable "they got in" + * signals; mere card access (badge/grant) does NOT count. */ + wentThroughFlow: boolean hasCardAccess: boolean cardAccessGrantedAt: string | null | undefined skipBadges: string[] userBadges?: Array<{ code: string; earnedAt?: string | Date | null }> }): CardUnlockHistoryEntry | null { - if (!args.hasIssuedCard) return null + if (!args.wentThroughFlow) return null if (!args.hasCardAccess) return null let timestamp = args.cardAccessGrantedAt ?? undefined diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx index 83965975f..16f7a8024 100644 --- a/src/components/Home/HomeHistory.tsx +++ b/src/components/Home/HomeHistory.tsx @@ -24,7 +24,7 @@ import { useCardPioneerInfo } from '@/hooks/useCardPioneerInfo' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useWallet } from '@/hooks/wallet/useWallet' import { BadgeStatusItem } from '@/components/Badges/BadgeStatusItem' -import { isBadgeHistoryItem } from '@/components/Badges/badge.types' +import { isBadgeHistoryItem, type BadgeHistoryEntry } from '@/components/Badges/badge.types' import { useUserInteractions } from '@/hooks/useUserInteractions' import { completeHistoryEntry } from '@/utils/history.utils' import { formatUnits } from 'viem' @@ -123,7 +123,10 @@ const HomeHistory = ({ ), }) - // Combine fetched history with real-time updates + // Combine fetched history with real-time updates. Heterogeneous feed + // (txns + synthetic kyc/badge/card-unlock rows); downstream type guards + // narrow per-row, so the state container stays loosely typed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any const [combinedEntries, setCombinedEntries] = useState>([]) // get all the user ids from the combined entries to check for interactions @@ -153,14 +156,17 @@ const HomeHistory = ({ // Process entries asynchronously to handle completeHistoryEntry const processEntries = async () => { // Start with the fetched entries - const entries: Array = [...historyData.entries] + const entries: Array = [ + ...historyData.entries, + ] // inject badge entries using user's badges (newest first) and earnedAt chronology // filter out beta tester badge — it creates confusing first impressions for new users if (isViewingOwnHistory) { const badges = (user?.user?.badges ?? []).filter((b) => b.code !== 'BETA_TESTER') badges.forEach((b) => { - if (!b.earnedAt) return + // Need both earnedAt (sort key) and id (React key + dedup uuid). + if (!b.earnedAt || !b.id) return entries.push({ isBadge: true, uuid: b.id, @@ -169,7 +175,7 @@ const HomeHistory = ({ name: b.name, description: b.description ?? undefined, iconUrl: b.iconUrl ?? undefined, - } as any) + }) }) } @@ -242,7 +248,10 @@ const HomeHistory = ({ // badge. if (isViewingOwnHistory && cardInfo) { const unlock = deriveCardUnlockEntry({ - hasIssuedCard: (rainOverview?.cards.length ?? 0) > 0, + // "Went through the flow" = saw the celebration (BE-persisted) + // OR holds a card (a cardholder definitionally went through, + // and this keeps pre-wiring cardholders). NOT mere access. + wentThroughFlow: !!cardInfo.skipCelebrationSeen || (rainOverview?.cards.length ?? 0) > 0, hasCardAccess: cardInfo.hasCardAccess, cardAccessGrantedAt: cardInfo.waitlistReleasedAt, skipBadges: cardInfo.skipBadges, diff --git a/src/services/card.ts b/src/services/card.ts index a38a27d26..47c6e79b6 100644 --- a/src/services/card.ts +++ b/src/services/card.ts @@ -30,6 +30,11 @@ export interface CardInfoResponse { waitlistReleasedAt: string | null /** Skip-badge codes the user holds (subset of SKIP_BADGE_CODES on BE). */ skipBadges: string[] + /** True once the user dismissed the skip-badge celebration (BE-persisted + * cardWaitlistSkipCelebrationSeenAt). Optional: undefined until the BE + * change ships, treated as "not seen". Gates the activity-feed + * share-asset row. */ + skipCelebrationSeen?: boolean } export interface WaitlistStateResponse { @@ -106,4 +111,21 @@ export const cardApi = { } return (await response.json()) as { grantedAt: string } }, + + /** POST /card/celebration-seen — idempotent write-once stamp of the + * skip-badge celebration. Called when the user dismisses the celebration + * so the activity-feed share-asset row knows they went through the flow. */ + markCelebrationSeen: async (): Promise<{ seenAt: string }> => { + const response = await fetchWithSentry(`${PEANUT_API_URL}/card/celebration-seen`, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: '{}', + cache: 'no-store', + }) + if (!response.ok) { + const err = await response.json().catch(() => ({})) + throw new Error(err.message || err.error || 'Failed to mark celebration seen') + } + return (await response.json()) as { seenAt: string } + }, }