Skip to content
Closed
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
13 changes: 12 additions & 1 deletion src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}}
Expand Down
28 changes: 14 additions & 14 deletions src/components/Card/__tests__/cardUnlock.types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -18,20 +18,20 @@ 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: [],
})
).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'],
Expand All @@ -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: [],
Expand All @@ -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: [],
Expand Down
31 changes: 19 additions & 12 deletions src/components/Card/cardUnlock.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
21 changes: 15 additions & 6 deletions src/components/Home/HomeHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<Array<any>>([])

// get all the user ids from the combined entries to check for interactions
Expand Down Expand Up @@ -153,14 +156,17 @@ const HomeHistory = ({
// Process entries asynchronously to handle completeHistoryEntry
const processEntries = async () => {
// Start with the fetched entries
const entries: Array<HistoryEntry | KycHistoryEntry | CardUnlockHistoryEntry> = [...historyData.entries]
const entries: Array<HistoryEntry | KycHistoryEntry | CardUnlockHistoryEntry | BadgeHistoryEntry> = [
...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,
Expand All @@ -169,7 +175,7 @@ const HomeHistory = ({
name: b.name,
description: b.description ?? undefined,
iconUrl: b.iconUrl ?? undefined,
} as any)
})
})
}

Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions src/services/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
},
}
Loading