Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"i18n-iso-countries": "^7.13.0",
"iban-to-bic": "^1.4.0",
"js-cookie": "^3.0.5",
"lottie-web": "^5.13.0",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"next-intl": "^4.13.1",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 99 additions & 0 deletions src/app/(mobile-ui)/dev/components/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import Checkbox from '@/components/0_Bruddle/Checkbox'
import CopyField from '@/components/Global/CopyField'
import Loading from '@/components/Global/Loading'
import PeanutLoading from '@/components/Global/PeanutLoading'
import PeanutMascot from '@/components/Global/PeanutMascot'
import type { MascotPose } from '@/components/Global/PeanutMascot/PeanutMascot.types'
import ErrorAlert from '@/components/Global/ErrorAlert'
import EmptyState from '@/components/Global/EmptyStates/EmptyState'
import NoDataEmptyState from '@/components/Global/EmptyStates/NoDataEmptyState'
Expand All @@ -35,11 +37,26 @@ const TOC: { id: string; label: string; icon: IconName }[] = [
{ id: 'cards', label: 'Cards', icon: 'docs' },
{ id: 'inputs', label: 'Inputs', icon: 'clip' },
{ id: 'feedback', label: 'Feedback', icon: 'meter' },
{ id: 'mascot', label: 'Mascot', icon: 'smile' },
{ id: 'navigation', label: 'Navigation', icon: 'link' },
{ id: 'layouts', label: 'Layouts', icon: 'switch' },
{ id: 'patterns', label: 'Patterns', icon: 'bulb' },
]

// Every pose, with the screen each one leads. Keep in sync with MascotPose.
const MASCOT_POSES: { pose: MascotPose; use: string }[] = [
{ pose: 'cheering', use: 'payment / claim success' },
{ pose: 'thinking', use: 'loading, verification waits' },
{ pose: 'too-cool', use: 'passkey setup, confident flex' },
{ pose: 'pointing', use: 'waitlist, look-over-there' },
{ pose: 'sad', use: 'validation errors' },
{ pose: 'waving-hello', use: 'greetings, invites, setup' },
{ pose: 'waving-chill', use: 'landing hero, low-key wins' },
{ pose: 'walking', use: 'physical card on the way' },
{ pose: 'pointing-down', use: 'marketing CTA above a card' },
{ pose: 'worried', use: 'errors, empty states' },
]

const ALL_ICONS: IconName[] = [
'alert',
'alert-filled',
Expand Down Expand Up @@ -828,6 +845,88 @@ export default function ComponentsPage() {

<Divider />

{/* ━━━━━━━━━━━━━━━━━━ MASCOT ━━━━━━━━━━━━━━━━━━ */}
<div id="mascot" className="scroll-mt-16 space-y-6">
<Section
title="PeanutMascot"
importPath={`import PeanutMascot from '@/components/Global/PeanutMascot'`}
status="production"
quality={5}
>
<p className="text-xs text-grey-1">
the animated mascot. ten poses, each a lottie comp loaded on demand — one pose per screen,
not all ten. plays on a shared clock, holds a static frame under prefers-reduced-motion, and
pauses when scrolled out of view.
</p>
<PropTable
rows={[
['pose', MASCOT_POSES.map(({ pose }) => pose).join(' | '), '(required)'],
['className', 'sizing + positioning classes', '(none)'],
['alt', 'string — omit to make it decorative', '(none)'],
['loop', 'boolean', 'true'],
]}
/>
<div className="rounded-sm border border-error bg-error-1/10 p-3 text-xs">
<span className="font-bold">sizing:</span> give it a{' '}
<span className="font-bold">height</span>, not a square. the host takes its aspect from the
pose, so <span className="font-mono">h-48 w-auto</span> renders every pose at that height. a
square box fits the wider of the two dimensions instead, which draws a wide pose like{' '}
<span className="font-mono">waving-chill</span> ~23% shorter than a narrow one like{' '}
<span className="font-mono">thinking</span>. prefer the shared{' '}
<span className="font-mono">MASCOT_HERO_CLASS</span> /{' '}
<span className="font-mono">MASCOT_STATE_CLASS</span> over a one-off box.
</div>
<CopySnippet
code={`<PeanutMascot pose="cheering" className="size-24" alt="Peanut cheering" />`}
/>
</Section>

<Section title="All ten poses">
<p className="text-xs text-grey-1">
same 96px box for every pose — the component fits the artwork, not the canvas, so they read
at a consistent size side by side.
</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{MASCOT_POSES.map(({ pose, use }) => (
<div
key={pose}
className="flex flex-col items-center gap-1 rounded-sm border border-n-1 bg-primary-3/20 p-2"
>
<PeanutMascot pose={pose} className="size-24" />
<span className="font-mono text-[11px] font-bold">{pose}</span>
<span className="text-center text-[10px] leading-tight text-grey-1">{use}</span>
</div>
))}
</div>
</Section>

<Section title="Labelled vs decorative">
<p className="text-xs text-grey-1">
pass <span className="font-mono">alt</span> when the mascot carries meaning a screen reader
needs (it becomes <span className="font-mono">role=&quot;img&quot;</span> with that label).
omit it next to copy that already says the same thing — the host goes{' '}
<span className="font-mono">aria-hidden</span>.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col items-center gap-1 rounded-sm border border-n-1 p-2">
<PeanutMascot pose="waving-hello" className="size-24" alt="Peanut waving hello" />
<span className="text-[10px] font-bold">labelled</span>
<span className="text-center text-[10px] text-grey-1">role=&quot;img&quot;</span>
</div>
<div className="flex flex-col items-center gap-1 rounded-sm border border-n-1 p-2">
<PeanutMascot pose="waving-hello" className="size-24" />
<span className="text-[10px] font-bold">decorative</span>
<span className="text-center text-[10px] text-grey-1">aria-hidden</span>
</div>
</div>
<CopySnippet
code={`<PeanutMascot pose="sad" className="size-24" alt="Something went wrong" />\n<PeanutMascot pose="sad" className="size-24" />`}
/>
</Section>
</div>

<Divider />

{/* ━━━━━━━━━━━━━━━━━━ NAVIGATION ━━━━━━━━━━━━━━━━━━ */}
<div id="navigation" className="scroll-mt-16 space-y-6">
<Section
Expand Down
9 changes: 5 additions & 4 deletions src/app/(mobile-ui)/dev/home-ctas/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import { type ReactNode } from 'react'
import type { StaticImageData } from 'next/image'
import { type IconName } from '@/components/Global/Icons/Icon'
import type { MascotPose } from '@/components/Global/PeanutMascot/PeanutMascot.types'
import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner'
import CarouselCTA from '@/components/Home/HomeCarouselCTA/CarouselCTA'
import ActivationCTAs from '@/components/Home/ActivationCTAs'
import { type ActivationStep } from '@/hooks/useActivationStatus'
import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg'
import { PeanutWavingHello } from '@/assets/mascot'
import DevNoteCard from '../_components/DevNoteCard'
import DevPageShell from '../_components/DevPageShell'
import DevSectionLabel from '../_components/DevSectionLabel'
Expand Down Expand Up @@ -39,6 +39,7 @@ type CarouselPreview = {
iconSize?: number
logo?: StaticImageData
logoSize?: number
mascotPose?: MascotPose
isPerkClaim?: boolean
}

Expand Down Expand Up @@ -123,10 +124,9 @@ const CAROUSEL_PREVIEWS: CarouselPreview[] = [
},
{
id: 'user-interview',
label: 'User-interview invite (flag-gated campaign, logo variant)',
label: 'User-interview invite (flag-gated campaign, mascot variant)',
icon: 'peanut-support',
logo: PeanutWavingHello,
logoSize: 44,
mascotPose: 'waving-hello',
iconContainerClassName: 'size-11',
title: 'Help shape Peanut',
description: "You're one of our most active users. Book a 15-min call with the team.",
Expand Down Expand Up @@ -198,6 +198,7 @@ export default function HomeCTAsPreviewPage() {
iconSize={cta.iconSize}
logo={cta.logo}
logoSize={cta.logoSize}
mascotPose={cta.mascotPose}
isPerkClaim={cta.isPerkClaim}
onClose={noop(`close ${cta.id}`)}
onClick={noop(`click ${cta.id}`)}
Expand Down
14 changes: 8 additions & 6 deletions src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,16 @@ jest.mock('@/assets/payment-apps', () => ({
PIX: '/pix.png',
}))

// The page imports PeanutThinking from @/assets/mascot and STAR_STRAIGHT_ICON
// from @/assets/icons directly — mock those paths, not the @/assets barrel, and
// keep sibling exports (e.g. PEANUTMAN, ETHEREUM_ICON used by QRScanner) intact.
jest.mock('@/assets/mascot', () => ({
...jest.requireActual('@/assets/mascot'),
PeanutThinking: '/peanut-guy.gif',
// PeanutMascot loads lottie-web, which needs a canvas jsdom does not have.
jest.mock('@/components/Global/PeanutMascot', () => ({
__esModule: true,
default: ({ pose, alt }: { pose: string; alt?: string }) => (
<div data-testid="peanut-mascot" data-pose={pose} aria-label={alt} />
),
}))

// The page imports STAR_STRAIGHT_ICON from @/assets/icons directly — mock that
// path, not the @/assets barrel, and keep sibling exports intact.
jest.mock('@/assets/icons', () => ({
...jest.requireActual('@/assets/icons'),
STAR_STRAIGHT_ICON: '/star.png',
Expand Down
14 changes: 7 additions & 7 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import InviteFriendsModal from '@/components/Global/InviteFriendsModal'
import { SoundPlayer } from '@/components/Global/SoundPlayer'
import { useQueryClient, useQuery } from '@tanstack/react-query'
import { shootDoubleStarConfetti } from '@/utils/confetti'
import { PeanutThinking } from '@/assets/mascot'
import PeanutMascot from '@/components/Global/PeanutMascot'
import { STAR_STRAIGHT_ICON } from '@/assets/icons'
import { useAuth } from '@/context/authContext'
import { PointsAction } from '@/services/services.types'
Expand Down Expand Up @@ -1681,13 +1681,13 @@ const QrPayPageLoading = ({ message }: { message: string }) => {
return (
<div className="my-auto flex h-full w-full flex-col items-center justify-center space-y-4">
<div className="relative">
<Image
src={PeanutThinking}
unoptimized
{/* left-1/2 + -translate-x-1/2 centres this over the card below. The old
<Image layout="fill"> got its centring from the inline inset:0 that
next/image emitted, which silently overrode the size classes. */}
<PeanutMascot
pose="thinking"
alt={t('peanutManAlt')}
layout="fill"
objectFit="contain"
className="absolute z-0 h-32 w-32 -translate-y-20 "
className="absolute left-1/2 z-0 h-32 w-32 -translate-x-1/2 -translate-y-20"
/>

<Card className="relative z-10 flex w-full flex-col items-center gap-4 p-4">
Expand Down
3 changes: 1 addition & 2 deletions src/app/(setup)/setup/finish/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Suspense } from 'react'
import PeanutLoading from '@/components/Global/PeanutLoading'
import { SetupWrapper } from '@/components/Setup/components/SetupWrapper'
import SignTestTransaction from '@/components/Setup/Views/SignTestTransaction'
import { PeanutWhistling } from '@/assets/mascot'
import { useAuth } from '@/context/authContext'
import { useTranslations } from 'next-intl'

Expand All @@ -20,7 +19,7 @@ function FinishSetupPageContent() {
<SetupWrapper
layoutType="signup"
screenId="sign-test-transaction"
image={PeanutWhistling.src}
image={{ pose: 'waving-chill' }}
showLogoutButton={true}
onLogout={logoutUser}
isLoggingOut={isLoggingOut}
Expand Down
3 changes: 1 addition & 2 deletions src/app/(setup)/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useAuth } from '@/context/authContext'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/0_Bruddle/Button'
import { PeanutWavingHello } from '@/assets/mascot'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { useTranslations } from 'next-intl'
Expand Down Expand Up @@ -263,7 +262,7 @@ function SetupPageContent() {
<SetupWrapper
layoutType="signup"
screenId="welcome"
image={PeanutWavingHello.src}
image={{ pose: 'waving-hello' }}
title={t('existingSession.title')}
description={t('existingSession.description', { username: existingSessionUsername })}
contentClassName="flex flex-col items-center justify-center gap-5"
Expand Down
5 changes: 2 additions & 3 deletions src/app/maintenance/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
'use client'
import { PeanutCrying } from '@/assets/mascot'
import { Button } from '@/components/0_Bruddle/Button'
import Image from 'next/image'
import PeanutMascot from '@/components/Global/PeanutMascot'
import { useRouter } from 'next/navigation'

const MaintenancePage = () => {
const router = useRouter()
return (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-6">
<Image src={PeanutCrying.src} unoptimized alt="Maintenance" width={250} height={250} />
<PeanutMascot pose="worried" alt="Maintenance" className="size-64" />
<h1 className="text-3xl font-bold text-black">Under Maintenance</h1>
<p className="text-center text-lg text-gray-1">
We are currently going through maintenance. We should be back online shortly. Sorry for the
Expand Down
Loading
Loading