From ba81082427f42d46d9cd634162a3a75da1ee12b7 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 10:04:23 -0700 Subject: [PATCH 01/48] chore: update solana program id to fresh address --- backend/env.example | 2 +- contracts/solana/cryptopets/Anchor.toml | 4 ++-- contracts/solana/cryptopets/programs/cryptopets/src/lib.rs | 2 +- indexer-go/internal/solana/decode_test.go | 2 +- indexer-go/internal/solana/idl/cryptopets.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/env.example b/backend/env.example index c5098fcf..dbeada91 100644 --- a/backend/env.example +++ b/backend/env.example @@ -37,7 +37,7 @@ DIRECT_URL="postgresql://postgres.:@aws-1-.pooler # HELIUS_RPC_URL : full Helius RPC URL incl. ?api-key= (devnet or mainnet host). # SOLANA_PROGRAM_ID : CryptoPets program id (base58) whose accounts we index. # HELIUS_RPC_URL=https://devnet.helius-rpc.com/?api-key= -# SOLANA_PROGRAM_ID=78AXV46ks5oFoJHkukvbsfZTJixdj2MeStzuC6thiUry +# SOLANA_PROGRAM_ID=88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k # # Shared secret Helius sends in the webhook Authorization header — set it both # here and in the Helius webhook's "Authorization Header" so POST /api/webhooks/ diff --git a/contracts/solana/cryptopets/Anchor.toml b/contracts/solana/cryptopets/Anchor.toml index fc5d9e81..fa2fbeeb 100644 --- a/contracts/solana/cryptopets/Anchor.toml +++ b/contracts/solana/cryptopets/Anchor.toml @@ -6,10 +6,10 @@ resolution = true skip-lint = false [programs.localnet] -cryptopets = "78AXV46ks5oFoJHkukvbsfZTJixdj2MeStzuC6thiUry" +cryptopets = "88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k" [programs.devnet] -cryptopets = "78AXV46ks5oFoJHkukvbsfZTJixdj2MeStzuC6thiUry" +cryptopets = "88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k" [provider] cluster = "devnet" diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs index 714fcb03..d2dc7719 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs @@ -7,7 +7,7 @@ pub mod utils; use anchor_lang::{prelude::*, solana_program::system_program}; use instructions::*; -declare_id!("78AXV46ks5oFoJHkukvbsfZTJixdj2MeStzuC6thiUry"); +declare_id!("88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k"); #[program] pub mod cryptopets { diff --git a/indexer-go/internal/solana/decode_test.go b/indexer-go/internal/solana/decode_test.go index eddb3627..7767d739 100644 --- a/indexer-go/internal/solana/decode_test.go +++ b/indexer-go/internal/solana/decode_test.go @@ -233,7 +233,7 @@ func buildBattleLog(attacker, defender uint32, attackerWon bool) string { func TestParseBattleResults(t *testing.T) { logs := []string{ - "Program 78AXV46ks5oFoJHkukvbsfZTJixdj2MeStzuC6thiUry invoke [1]", + "Program 88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k invoke [1]", "Program log: Instruction: SettleBattle", buildBattleLog(7, 9, true), programDataPrefix + "bm90LWFuLWV2ZW50", // valid b64, wrong shape — skipped diff --git a/indexer-go/internal/solana/idl/cryptopets.json b/indexer-go/internal/solana/idl/cryptopets.json index 9a276ef7..8183f56b 100644 --- a/indexer-go/internal/solana/idl/cryptopets.json +++ b/indexer-go/internal/solana/idl/cryptopets.json @@ -1,5 +1,5 @@ { - "address": "78AXV46ks5oFoJHkukvbsfZTJixdj2MeStzuC6thiUry", + "address": "88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k", "metadata": { "name": "cryptopets", "version": "0.1.0", From 32340686ec6f309b801e9b5e8b1b33d8bc90f105 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 14:31:55 -0400 Subject: [PATCH 02/48] feat: map v2 pet fields from PetAccount in mapSolanaPet --- shared/src/utils/pets/mapSolanaPet.ts | 6 ++++ shared/src/utils/solana/accountClient.ts | 1 + shared/tests/utils/pets/mapSolanaPet.test.ts | 34 +++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/shared/src/utils/pets/mapSolanaPet.ts b/shared/src/utils/pets/mapSolanaPet.ts index eaac6e2f..14294230 100644 --- a/shared/src/utils/pets/mapSolanaPet.ts +++ b/shared/src/utils/pets/mapSolanaPet.ts @@ -66,6 +66,12 @@ export const mapSolanaPet = (row: SolanaPetAccountRow): Pet => { lossCount: toNumber(a.lossCount), readyAt: toNumber(a.readyTime), assetKey: toBase58Key(a.asset), + xp: toNumber(a.xp) || undefined, + generation: toNumber(a.generation), + speciesId: toNumber(a.speciesId) || undefined, + breedCount: toNumber(a.breedCount), + breedReadyAt: toNumber(a.breedReadyTime) || undefined, + trainReadyAt: toNumber(a.trainReadyTime) || undefined, spouseId: spouseId !== 0 ? spouseId : undefined, marriageCooldownUntil: toNumber(a.marriageCooldownUntil) || undefined, }; diff --git a/shared/src/utils/solana/accountClient.ts b/shared/src/utils/solana/accountClient.ts index f3ddcea1..e366fa50 100644 --- a/shared/src/utils/solana/accountClient.ts +++ b/shared/src/utils/solana/accountClient.ts @@ -3,6 +3,7 @@ import { Buffer } from 'buffer'; import { PublicKey } from '@solana/web3.js'; import type { Idl, Program } from '@coral-xyz/anchor'; import { PET_ACCOUNT_ID_MEMCMP_OFFSET } from './constants'; +import { petPdaByAsset } from './pdas'; export type AnchorAccountClient = { fetch: (key: unknown) => Promise; diff --git a/shared/tests/utils/pets/mapSolanaPet.test.ts b/shared/tests/utils/pets/mapSolanaPet.test.ts index 3252a2a5..3a103245 100644 --- a/shared/tests/utils/pets/mapSolanaPet.test.ts +++ b/shared/tests/utils/pets/mapSolanaPet.test.ts @@ -27,7 +27,7 @@ describe('mapSolanaPet', () => { readyTime: 1_700_000_000, }), ); - expect(pet).toEqual({ + expect(pet).toMatchObject({ id: '12', chain: 'solana', name: 'Luna', @@ -76,6 +76,38 @@ describe('mapSolanaPet', () => { expect(pet.readyAt).toBe(1234); }); + it('maps v2 fields: xp, generation, speciesId, breedCount, breedReadyAt, trainReadyAt', () => { + const pet = mapSolanaPet( + row({ + xp: 42, + generation: 2, + speciesId: 5, + breedCount: 3, + breedReadyTime: 1_700_001_000, + trainReadyTime: 1_700_002_000, + }), + ); + expect(pet.xp).toBe(42); + expect(pet.generation).toBe(2); + expect(pet.speciesId).toBe(5); + expect(pet.breedCount).toBe(3); + expect(pet.breedReadyAt).toBe(1_700_001_000); + expect(pet.trainReadyAt).toBe(1_700_002_000); + }); + + it('omits xp, speciesId, breedReadyAt, trainReadyAt when zero/missing', () => { + const pet = mapSolanaPet(row({ xp: 0, speciesId: 0, breedReadyTime: 0, trainReadyTime: 0 })); + expect(pet.xp).toBeUndefined(); + expect(pet.speciesId).toBeUndefined(); + expect(pet.breedReadyAt).toBeUndefined(); + expect(pet.trainReadyAt).toBeUndefined(); + }); + + it('maps generation=0 (gen-0 starter)', () => { + const pet = mapSolanaPet(row({ generation: 0 })); + expect(pet.generation).toBe(0); + }); + it('defaults missing numeric fields to 0 / 0n', () => { const pet = mapSolanaPet(row({})); expect(pet.id).toBe('0'); From 271bdaf12e066a60436bc3a04b2ff027d61fb6dc Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 14:42:08 -0400 Subject: [PATCH 03/48] feat: resolve parent2Owner for cross-owner (married) breeding --- shared/src/hooks/adapters/useSolanaAdapter.ts | 32 +++++++++++++++++-- shared/src/utils/solana/accountClient.ts | 20 ++++++++++++ shared/tests/hooks/useSolanaAdapter.test.tsx | 13 ++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index 0f0883d6..be9ba111 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -2,9 +2,11 @@ import { useMemo } from 'react'; import { PublicKey } from '@solana/web3.js'; import { usePetActions } from '../chains/solana/usePetActions'; import { usePets as useSolanaPets } from '../chains/solana/usePets'; +import { useProgram } from '../chains/solana/useProgram'; import { useSolanaAnchor } from '../../contexts/SolanaAnchorContext'; import { mapSolanaPet, type SolanaPetAccountRow } from '../../utils/pets/mapSolanaPet'; import { formatSolanaActionError } from '../../utils/solana'; +import { fetchAssetByPetId, fetchMarriageOwnerSnapshot } from '../../utils/solana/accountClient'; import type { Pet } from '../../types/pet'; import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; @@ -52,6 +54,7 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte const owner = enabled && signingWallet?.publicKey ? signingWallet.publicKey : null; const actions = usePetActions(); + const { program, programId } = useProgram(); const petsQuery = useSolanaPets(owner); const solanaPets = useMemo(() => { @@ -119,16 +122,39 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte isPending: actions.battlePets.isPending, }; - const breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string }> = { - async mutateAsync({ parentId1, parentId2, name }) { + const breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }> = { + async mutateAsync({ parentId1, parentId2, name, crossOwner }) { const parent1AssetKey = requireAssetKey(parentId1); const parent2Pet = solanaPets.find(p => p.id === parentId2); + + let parent2AssetKey = parent2Pet?.assetKey; + let parent2Owner: string | undefined; + + if (crossOwner) { + if (!program || !programId) throw new Error('Solana program not ready — cannot resolve spouse owner for cross-owner breed'); + // Spouse pet belongs to another wallet — look up their asset + owner on-chain. + if (!parent2AssetKey) { + const assetPk = await fetchAssetByPetId(program, Number(parentId2)); + if (!assetPk) throw new Error(`Spouse pet #${parentId2} not found on-chain`); + parent2AssetKey = assetPk.toBase58(); + } + // marriageOwnerSnapshot = spouse wallet captured at accept_marriage time. + const snapshot = await fetchMarriageOwnerSnapshot( + program, + programId, + new PublicKey(parent2AssetKey), + ); + if (!snapshot) throw new Error(`Pet #${parentId2} is not married or marriage owner not found`); + parent2Owner = snapshot.toBase58(); + } + await actions.breedPets.mutateAsync({ parent1Id: Number(parentId1), parent2Id: Number(parentId2), name, parent1AssetKey, - parent2AssetKey: parent2Pet?.assetKey, + parent2AssetKey, + parent2Owner, }); }, lifecycle: toLc(actions.breedPets), diff --git a/shared/src/utils/solana/accountClient.ts b/shared/src/utils/solana/accountClient.ts index e366fa50..dbbb25f9 100644 --- a/shared/src/utils/solana/accountClient.ts +++ b/shared/src/utils/solana/accountClient.ts @@ -25,6 +25,26 @@ export const getAccountClient = (program: Program, name: string): AnchorAcc return client as AnchorAccountClient; } +/** + * Fetch the `marriage_owner_snapshot` field from a `PetAccount` keyed by its Core asset + * pubkey. This is the spouse's wallet captured at `accept_marriage` time and is the + * correct `parent2Owner` for cross-owner Solana breeding. Returns `null` when the account + * doesn't exist or the snapshot is the zero pubkey (pet is not married). + */ +export const fetchMarriageOwnerSnapshot = async ( + program: Program, + programId: PublicKey, + assetKey: PublicKey, +): Promise => { + const [petPda] = petPdaByAsset(programId, assetKey.toBase58()); + const account = await getAccountClient(program, 'petAccount').fetchNullable(petPda); + if (!account) return null; + const snap = (account as Record).marriageOwnerSnapshot; + if (!snap || typeof snap !== 'object') return null; + const pk = snap as PublicKey; + return pk.equals(PublicKey.default) ? null : pk; +} + /** * Look up any `PetAccount` by its numeric ID using a memcmp filter on the `id` field * (offset 8, 4 bytes LE). Returns the on-chain Core asset `PublicKey`, or `null` if not found. diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index 5aad4a77..c2a54662 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -37,6 +37,9 @@ const anchor = { signingWallet: { publicKey: Keypair.generate().publicKey } as { vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ usePetActions: () => actions })); vi.mock('../../src/hooks/chains/solana/usePets', () => ({ usePets: () => petsQuery })); vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ useSolanaAnchor: () => anchor })); +vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ + useProgram: () => ({ program: null, programId: null, provider: null, isConfigured: false, isLoading: false, isFetching: false, error: null, refetch: vi.fn(), isReady: false }), +})); // Avoid loading Switchboard builders; expose only the real error formatter. vi.mock('../../src/utils/solana', async () => { const mod = await import('../../src/utils/solana/parseSolanaTransactionError'); @@ -117,9 +120,19 @@ describe('useSolanaAdapter', () => { name: 'Baby', parent1AssetKey: ASSET_1, parent2AssetKey: ASSET_2, + parent2Owner: undefined, }); }); + it('cross-owner breed: errors when program not ready (null programId)', async () => { + // With programId=null the adapter can't look up the spouse on-chain. + // crossOwner=true should throw rather than silently send the wrong owner. + const { result } = renderHook(() => useSolanaAdapter({ enabled: true })); + await expect( + result.current.breedPets.mutateAsync({ parentId1: '1', parentId2: '99', name: 'Baby', crossOwner: true }), + ).rejects.toThrow(/not found on-chain|program.*not ready|programId/i); + }); + it('includes defenderOwner and attackerAssetKey in battle', async () => { const { result } = renderHook(() => useSolanaAdapter({ enabled: true })); From 25e92f778ce8c22d8e98da3e821fe00c42ea8bd7 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 14:56:04 -0400 Subject: [PATCH 04/48] feat: parse BattleResolved event and return firstWins from settle tx --- shared/src/hooks/adapters/noneAdapter.ts | 4 +- shared/src/hooks/adapters/types.ts | 7 ++-- shared/src/hooks/adapters/useEvmAdapter.ts | 3 +- shared/src/hooks/adapters/useSolanaAdapter.ts | 17 ++++++-- .../src/hooks/chains/solana/usePetActions.ts | 16 ++++---- shared/src/hooks/useBattlePets.ts | 14 +++++-- .../utils/solana/battleWithSwitchboardVrf.ts | 41 ++++++++++++++++--- shared/tests/hooks/useSolanaAdapter.test.tsx | 19 +++++---- 8 files changed, 89 insertions(+), 32 deletions(-) diff --git a/shared/src/hooks/adapters/noneAdapter.ts b/shared/src/hooks/adapters/noneAdapter.ts index 715dfc46..2ae46438 100644 --- a/shared/src/hooks/adapters/noneAdapter.ts +++ b/shared/src/hooks/adapters/noneAdapter.ts @@ -12,9 +12,9 @@ const NONE_CAPABILITIES: ChainCapabilities = { parseError: (_err, fallback) => ({ message: fallback, isUserRejection: false, isContractError: false }), }; -const disconnectedMutation = (action: PetAction): AdapterMutation => { +const disconnectedMutation = (action: PetAction): AdapterMutation => { return { - mutateAsync: async () => { throw new NoActiveChainError(action); }, + mutateAsync: async (): Promise => { throw new NoActiveChainError(action); }, lifecycle: { phase: 'idle', error: null, reset: () => undefined }, isPending: false, }; diff --git a/shared/src/hooks/adapters/types.ts b/shared/src/hooks/adapters/types.ts index 89757ab7..5d17930e 100644 --- a/shared/src/hooks/adapters/types.ts +++ b/shared/src/hooks/adapters/types.ts @@ -1,4 +1,5 @@ import type { Pet } from '../../types/pet'; +import type { BattleResolvedResult } from '../../types/battle'; export type TxPhase = | 'idle' @@ -15,8 +16,8 @@ export interface TxLifecycle { reset(): void; } -export interface AdapterMutation { - mutateAsync(args: TArgs): Promise; +export interface AdapterMutation { + mutateAsync(args: TArgs): Promise; lifecycle: TxLifecycle; isPending: boolean; } @@ -61,7 +62,7 @@ export interface ChainAdapter { trainPet: AdapterMutation<{ petId: string }>; renamePet: AdapterMutation<{ petId: string; name: string }>; transferPet: AdapterMutation<{ petId: string; to: string }>; - battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }>; + battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, BattleResolvedResult | null>; // crossOwner adds the stud fee (EVM married cross-owner breeding); ignored on Solana. breedPets: AdapterMutation<{ parentId1: string; parentId2: string; name: string; crossOwner?: boolean }>; } diff --git a/shared/src/hooks/adapters/useEvmAdapter.ts b/shared/src/hooks/adapters/useEvmAdapter.ts index 987a1e04..6ca611ab 100644 --- a/shared/src/hooks/adapters/useEvmAdapter.ts +++ b/shared/src/hooks/adapters/useEvmAdapter.ts @@ -174,11 +174,12 @@ export const useEvmAdapter = ({ enabled }: { enabled: boolean }): ChainAdapter // a VRF request, which the RPC can't gas-estimate (estimateGas returns the // block limit → "gas limit too high"), so a manual limit is REQUIRED — sized // like breed's working VRF path, not v1's synchronous-battle 300k. - const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }> = { + const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, null> = { async mutateAsync({ petId1, petId2 }) { if (!canWrite) throw new Error('EVM contract not configured'); if (fees.entropyFee == null) throw new Error('Entropy fee not loaded yet'); await battleW.writeContractAsync({ address: gameLogic, abi: gameLogicAbi, functionName: 'requestBattle', args: [BigInt(petId1), BigInt(petId2)], value: fees.entropyFee, gas: 800000n, chainId: evm?.chainId } as unknown as Parameters[0]); + return null; }, lifecycle: toLc(battleW, battleR), isPending: isInFlight(battleW, battleR), diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index be9ba111..c8a646a0 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -8,6 +8,7 @@ import { mapSolanaPet, type SolanaPetAccountRow } from '../../utils/pets/mapSola import { formatSolanaActionError } from '../../utils/solana'; import { fetchAssetByPetId, fetchMarriageOwnerSnapshot } from '../../utils/solana/accountClient'; import type { Pet } from '../../types/pet'; +import type { BattleResolvedResult } from '../../types/battle'; import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; export const SOLANA_CAPABILITIES: ChainCapabilities = { @@ -36,6 +37,14 @@ type SolanaMutation = { reset: () => void; }; +const resolveHash = (data: unknown): string | undefined => { + if (typeof data === 'string') return data; + if (data && typeof data === 'object' && 'sig' in data && typeof (data as { sig: unknown }).sig === 'string') { + return (data as { sig: string }).sig; + } + return undefined; +} + const toLc = (m: SolanaMutation): TxLifecycle => { let phase: TxPhase = 'idle'; if (m.isError) phase = 'error'; @@ -43,7 +52,7 @@ const toLc = (m: SolanaMutation): TxLifecycle => { else if (m.isPending) phase = 'awaiting-wallet'; return { phase, - hash: typeof m.data === 'string' ? m.data : undefined, + hash: resolveHash(m.data), error: m.error, reset: m.reset, }; @@ -109,14 +118,16 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte isPending: false, }; - const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }> = { + const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, BattleResolvedResult | null> = { async mutateAsync({ petId1, petId2, defenderOwner }) { - await actions.battlePets.mutateAsync({ + const { sig, firstWins } = await actions.battlePets.mutateAsync({ attackerPetId: Number(petId1), defenderPetId: Number(petId2), attackerAssetKey: requireAssetKey(petId1), ...(defenderOwner ? { defenderOwner } : {}), }); + if (firstWins === null) return null; + return { firstWins, sig, requestId: 0n, winnerId: 0n, loserId: 0n, vrfSeed: 0n, rounds: 0, winnerHpRemaining: 0, xpWin: 0, xpLoss: 0 }; }, lifecycle: toLc(actions.battlePets), isPending: actions.battlePets.isPending, diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index cfa6988b..96c76a1a 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -6,7 +6,7 @@ import { globalStatePda, petPdaByAsset, } from '../../../utils/solana/pdas'; -import { battleWithSwitchboardVrf } from '../../../utils/solana/battleWithSwitchboardVrf'; +import { battleWithSwitchboardVrf, type BattleVrfResult } from '../../../utils/solana/battleWithSwitchboardVrf'; import { breedWithSwitchboardVrf } from '../../../utils/solana/breedWithSwitchboardVrf'; import { mintWithSwitchboardVrf } from '../../../utils/solana/mintWithSwitchboardVrf'; import { useProgram } from './useProgram'; @@ -112,13 +112,13 @@ export const usePetActions = () => { * omitted it defaults to the signer (same-wallet battle); pass a foreign owner * pubkey for PvP against another player's pet. */ - const battlePets = useMutation({ - mutationFn: async (args: { - attackerPetId: number; - defenderPetId: number; - attackerAssetKey: string; - defenderOwner?: string; - }) => { + const battlePets = useMutation({ + mutationFn: async (args) => { const { program, programId, owner } = requireReady(); if (!provider) throw new Error('Solana provider is not ready'); return battleWithSwitchboardVrf({ diff --git a/shared/src/hooks/useBattlePets.ts b/shared/src/hooks/useBattlePets.ts index 158c9e03..3597f600 100644 --- a/shared/src/hooks/useBattlePets.ts +++ b/shared/src/hooks/useBattlePets.ts @@ -38,19 +38,27 @@ export const useBattlePets = (options?: UseBattlePetsOptions) => { onResolved: (result) => onSuccessRef.current?.(result), }); - // Solana: settlement is lifecycle-driven (reveal+settle resolve in-mutation). + // Solana: success fires from the mutateAsync return value (BattleResolvedResult | null). + // useTxSuccess is kept as a fallback only — the ref prevents double-firing. + const solanaBattleFiredRef = useRef(false); useTxSuccess(battlePets.lifecycle, useCallback(() => { - if (!isEvm) onSuccessRef.current?.(null); + if (!isEvm && !solanaBattleFiredRef.current) onSuccessRef.current?.(null); + solanaBattleFiredRef.current = false; }, [isEvm])); const mutate = async (args: BattlePetsArgs) => { battleFlow.reset(); + solanaBattleFiredRef.current = false; try { - await battlePets.mutateAsync({ + const result = await battlePets.mutateAsync({ petId1: args.petId1, petId2: args.petId2, defenderOwner: args.defenderOwner, }); + if (!isEvm) { + solanaBattleFiredRef.current = true; + onSuccessRef.current?.(result ?? null); + } } catch { // error tracked in battlePets.lifecycle.error } diff --git a/shared/src/utils/solana/battleWithSwitchboardVrf.ts b/shared/src/utils/solana/battleWithSwitchboardVrf.ts index 0ff0aae8..d692462a 100644 --- a/shared/src/utils/solana/battleWithSwitchboardVrf.ts +++ b/shared/src/utils/solana/battleWithSwitchboardVrf.ts @@ -1,4 +1,5 @@ import type { AnchorProvider, Program , Idl } from '@coral-xyz/anchor'; +import { EventParser } from '@coral-xyz/anchor'; import { Keypair, PublicKey, SystemProgram } from '@solana/web3.js'; import * as sb from '@switchboard-xyz/on-demand'; import { battleRequestPda, globalStatePda, petPdaByAsset } from './pdas'; @@ -13,6 +14,32 @@ import { } from './switchboardVrfTx'; import { sleep } from '../common'; +/** Parse `firstWins` from the `BattleResolved` Anchor event in settle tx logs. */ +const parseFirstWins = async ( + program: Program, + connection: AnchorProvider['connection'], + sig: string, +): Promise => { + try { + const tx = await connection.getTransaction(sig, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + const logs = tx?.meta?.logMessages ?? []; + const parser = new EventParser(program.programId, program.coder); + for (const event of parser.parseLogs(logs)) { + if (event.name === 'BattleResolved') { + return (event.data as { firstWins: boolean }).firstWins; + } + } + } catch { + // Non-fatal — caller gets null and UI falls back to stat-diff. + } + return null; +} + +export type BattleVrfResult = { sig: string; firstWins: boolean | null }; + const toPublicKey = (value: unknown): PublicKey => { if (value instanceof PublicKey) return value; if (value && typeof value === 'object' && 'toBase58' in value) { @@ -41,7 +68,7 @@ export type BattleWithVrfArgs = { }; /** Completes a battle whose commit phase succeeded but settle was never submitted. */ -const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { +const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { const { program, provider, programId, owner } = args; const connection = provider.connection; const [battleRequestKey] = battleRequestPda(programId, owner); @@ -90,14 +117,16 @@ const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { +export const battleWithSwitchboardVrf = async (args: BattleWithVrfArgs): Promise => { const resumed = await trySettlePendingBattle(args); if (resumed) return resumed; @@ -184,5 +213,7 @@ export const battleWithSwitchboardVrf = async (args: BattleWithVrfArgs): Promise computeUnitLimitMultiple: 1.3, }); // Reveal + settle after oracle fulfills randomness (wallet prompt 2 of 2). - return sendSignedTx(provider, settleTx); + const sig = await sendSignedTx(provider, settleTx); + const firstWins = await parseFirstWins(program, connection, sig); + return { sig, firstWins }; } diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index c2a54662..eeec6fcc 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import { Keypair } from '@solana/web3.js'; -const makeMutation = () => ({ - mutateAsync: vi.fn().mockResolvedValue(undefined), +const makeMutation = (resolvedValue: unknown = undefined) => ({ + mutateAsync: vi.fn().mockResolvedValue(resolvedValue), isPending: false, isSuccess: false, isError: false, error: null as Error | null, - data: undefined as string | undefined, + data: undefined as unknown, reset: vi.fn(), }); @@ -28,7 +28,7 @@ const actions = { levelUpPet: makeMutation(), trainPet: makeMutation(), renamePet: makeMutation(), - battlePets: makeMutation(), + battlePets: makeMutation({ sig: 'settle-sig', firstWins: true }), breedPets: makeMutation(), }; const petsQuery = { data: testPets, isLoading: false, isFetching: false, error: null, refetch: vi.fn() }; @@ -56,9 +56,14 @@ const validAddress = Keypair.generate().publicKey.toBase58(); beforeEach(() => { vi.clearAllMocks(); - Object.values(actions).forEach((m) => - Object.assign(m, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }), - ); + Object.assign(actions.mintPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.levelUpPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.trainPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.renamePet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.battlePets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.breedPets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + actions.battlePets.mutateAsync.mockResolvedValue({ sig: 'settle-sig', firstWins: true }); + actions.breedPets.mutateAsync.mockResolvedValue(undefined); anchor.signingWallet = { publicKey: Keypair.generate().publicKey }; }); From 3e634a00ae8e7a3197d35248f84aadb00ca2b228 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:03:12 -0400 Subject: [PATCH 05/48] feat: resolve explorer URL from connection rpcEndpoint --- shared/src/hooks/adapters/useSolanaAdapter.ts | 15 +++++++++++++-- shared/tests/hooks/useSolanaAdapter.test.tsx | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index c8a646a0..ad8eceb5 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -58,8 +58,16 @@ const toLc = (m: SolanaMutation): TxLifecycle => { }; } +/** Infer Solana Explorer cluster param from an RPC endpoint URL. */ +const clusterParam = (rpcEndpoint: string): string => { + if (rpcEndpoint.includes('devnet')) return 'devnet'; + if (rpcEndpoint.includes('mainnet')) return 'mainnet-beta'; + if (rpcEndpoint.includes('testnet')) return 'testnet'; + return `custom&customUrl=${encodeURIComponent(rpcEndpoint)}`; +}; + export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapter => { - const { signingWallet } = useSolanaAnchor(); + const { signingWallet, connection } = useSolanaAnchor(); const owner = enabled && signingWallet?.publicKey ? signingWallet.publicKey : null; const actions = usePetActions(); @@ -172,11 +180,14 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte isPending: actions.breedPets.isPending, }; + const explorerTxUrl = (hash: string) => + `https://explorer.solana.com/tx/${hash}?cluster=${clusterParam(connection.rpcEndpoint)}`; + return { kind: 'solana', address: signingWallet?.publicKey?.toBase58() ?? null, isConnected: enabled && Boolean(signingWallet?.publicKey), - capabilities: SOLANA_CAPABILITIES, + capabilities: { ...SOLANA_CAPABILITIES, explorerTxUrl }, pets: { data: solanaPets, isLoading: petsQuery.isLoading || petsQuery.isFetching, diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index eeec6fcc..a16f863b 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -36,7 +36,9 @@ const anchor = { signingWallet: { publicKey: Keypair.generate().publicKey } as { vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ usePetActions: () => actions })); vi.mock('../../src/hooks/chains/solana/usePets', () => ({ usePets: () => petsQuery })); -vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ useSolanaAnchor: () => anchor })); +vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ + useSolanaAnchor: () => ({ ...anchor, connection: { rpcEndpoint: 'https://api.devnet.solana.com' } }), +})); vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ useProgram: () => ({ program: null, programId: null, provider: null, isConfigured: false, isLoading: false, isFetching: false, error: null, refetch: vi.fn(), isReady: false }), })); @@ -75,6 +77,10 @@ describe('SOLANA_CAPABILITIES', () => { expect(SOLANA_CAPABILITIES.randomness.provider).toBe('switchboard'); }); + it('base explorerTxUrl returns null (cluster resolved at runtime)', () => { + expect(SOLANA_CAPABILITIES.explorerTxUrl('abc')).toBeNull(); + }); + it('validates base58 addresses', () => { expect(SOLANA_CAPABILITIES.address.isValid(validAddress)).toBe(true); expect(SOLANA_CAPABILITIES.address.isValid('not-base58!!')).toBe(false); @@ -93,7 +99,14 @@ describe('useSolanaAdapter', () => { expect(result.current.kind).toBe('solana'); expect(result.current.isConnected).toBe(true); expect(result.current.address).toBe(anchor.signingWallet!.publicKey.toString()); - expect(result.current.capabilities).toBe(SOLANA_CAPABILITIES); + expect(result.current.capabilities.chainLabel).toBe('Solana'); + }); + + it('explorerTxUrl includes devnet cluster from rpcEndpoint', () => { + const { result } = renderHook(() => useSolanaAdapter({ enabled: true })); + const url = result.current.capabilities.explorerTxUrl('mysig123'); + expect(url).toContain('explorer.solana.com/tx/mysig123'); + expect(url).toContain('cluster=devnet'); }); it('is disconnected without a signing wallet', () => { From 8a0d5f1582a53527168f8f52a6c536688f50140e Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:09:21 -0400 Subject: [PATCH 06/48] feat: expose awaiting-vrf lifecycle phase during oracle wait --- shared/src/hooks/adapters/useSolanaAdapter.ts | 18 ++++- .../src/hooks/chains/solana/usePetActions.ts | 70 ++++++++++++------- shared/src/hooks/useBattlePets.ts | 4 +- .../utils/solana/battleWithSwitchboardVrf.ts | 7 +- .../utils/solana/breedWithSwitchboardVrf.ts | 7 +- 5 files changed, 74 insertions(+), 32 deletions(-) diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index ad8eceb5..af3a1ace 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -126,6 +126,14 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte isPending: false, }; + const battleLc = useMemo(() => { + const lc = toLc(actions.battlePets); + if (actions.battleSubPhase === 'awaiting-vrf' && lc.phase === 'awaiting-wallet') { + return { ...lc, phase: 'awaiting-vrf' as TxPhase }; + } + return lc; + }, [actions.battlePets, actions.battleSubPhase]); + const battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }, BattleResolvedResult | null> = { async mutateAsync({ petId1, petId2, defenderOwner }) { const { sig, firstWins } = await actions.battlePets.mutateAsync({ @@ -137,7 +145,7 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte if (firstWins === null) return null; return { firstWins, sig, requestId: 0n, winnerId: 0n, loserId: 0n, vrfSeed: 0n, rounds: 0, winnerHpRemaining: 0, xpWin: 0, xpLoss: 0 }; }, - lifecycle: toLc(actions.battlePets), + lifecycle: battleLc, isPending: actions.battlePets.isPending, }; @@ -176,7 +184,13 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte parent2Owner, }); }, - lifecycle: toLc(actions.breedPets), + lifecycle: (() => { + const lc = toLc(actions.breedPets); + if (actions.breedSubPhase === 'awaiting-vrf' && lc.phase === 'awaiting-wallet') { + return { ...lc, phase: 'awaiting-vrf' as TxPhase }; + } + return lc; + })(), isPending: actions.breedPets.isPending, }; diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index 96c76a1a..9083ed4f 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { PublicKey, SystemProgram } from '@solana/web3.js'; import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; @@ -16,6 +17,9 @@ export const usePetActions = () => { const { signingWallet } = useSolanaAnchor(); const { program, programId, provider } = useProgram(); + const [battleSubPhase, setBattleSubPhase] = useState<'idle' | 'awaiting-vrf'>('idle'); + const [breedSubPhase, setBreedSubPhase] = useState<'idle' | 'awaiting-vrf'>('idle'); + const invalidateProgramQueries = () => queryClient.invalidateQueries({ queryKey: ['cryptopets'] }); const requireReady = () => { @@ -121,18 +125,24 @@ export const usePetActions = () => { mutationFn: async (args) => { const { program, programId, owner } = requireReady(); if (!provider) throw new Error('Solana provider is not ready'); - return battleWithSwitchboardVrf({ - program, - provider, - programId, - owner, - attackerPetId: args.attackerPetId, - defenderPetId: args.defenderPetId, - attackerAssetKey: args.attackerAssetKey, - ...(args.defenderOwner - ? { defenderOwner: new PublicKey(args.defenderOwner) } - : {}), - }); + setBattleSubPhase('idle'); + try { + return await battleWithSwitchboardVrf({ + program, + provider, + programId, + owner, + attackerPetId: args.attackerPetId, + defenderPetId: args.defenderPetId, + attackerAssetKey: args.attackerAssetKey, + ...(args.defenderOwner + ? { defenderOwner: new PublicKey(args.defenderOwner) } + : {}), + onCommitted: () => setBattleSubPhase('awaiting-vrf'), + }); + } finally { + setBattleSubPhase('idle'); + } }, onSuccess: invalidateProgramQueries, }); @@ -153,20 +163,26 @@ export const usePetActions = () => { }) => { const { program, programId, owner } = requireReady(); if (!provider) throw new Error('Solana provider is not ready'); - return breedWithSwitchboardVrf({ - program, - provider, - programId, - owner, - parent1Id: args.parent1Id, - parent2Id: args.parent2Id, - name: args.name, - parent1AssetKey: args.parent1AssetKey, - parent2AssetKey: args.parent2AssetKey, - ...(args.parent2Owner - ? { parent2Owner: new PublicKey(args.parent2Owner) } - : {}), - }); + setBreedSubPhase('idle'); + try { + return await breedWithSwitchboardVrf({ + program, + provider, + programId, + owner, + parent1Id: args.parent1Id, + parent2Id: args.parent2Id, + name: args.name, + parent1AssetKey: args.parent1AssetKey, + parent2AssetKey: args.parent2AssetKey, + ...(args.parent2Owner + ? { parent2Owner: new PublicKey(args.parent2Owner) } + : {}), + onCommitted: () => setBreedSubPhase('awaiting-vrf'), + }); + } finally { + setBreedSubPhase('idle'); + } }, onSuccess: invalidateProgramQueries, }); @@ -177,7 +193,9 @@ export const usePetActions = () => { trainPet, renamePet, battlePets, + battleSubPhase, breedPets, + breedSubPhase, walletPublicKey: signingWallet?.publicKey ?? null, walletConnected: Boolean(signingWallet?.publicKey), }; diff --git a/shared/src/hooks/useBattlePets.ts b/shared/src/hooks/useBattlePets.ts index 3597f600..c9342b90 100644 --- a/shared/src/hooks/useBattlePets.ts +++ b/shared/src/hooks/useBattlePets.ts @@ -77,8 +77,8 @@ export const useBattlePets = (options?: UseBattlePetsOptions) => { mutate, isPending, isConfirming: battlePets.lifecycle.phase === 'confirming' || (isEvm && battleFlow.phase === 'settling'), - isAwaitingVrf: isEvm && battleFlow.phase === 'awaiting-vrf', - phase: isEvm ? battleFlow.phase : undefined, + isAwaitingVrf: isEvm ? battleFlow.phase === 'awaiting-vrf' : battlePets.lifecycle.phase === 'awaiting-vrf', + phase: isEvm ? battleFlow.phase : (battlePets.lifecycle.phase === 'awaiting-vrf' ? 'awaiting-vrf' : undefined), result: battleFlow.result, reset, clearErrors: reset, diff --git a/shared/src/utils/solana/battleWithSwitchboardVrf.ts b/shared/src/utils/solana/battleWithSwitchboardVrf.ts index d692462a..3575abf2 100644 --- a/shared/src/utils/solana/battleWithSwitchboardVrf.ts +++ b/shared/src/utils/solana/battleWithSwitchboardVrf.ts @@ -65,11 +65,13 @@ export type BattleWithVrfArgs = { attackerAssetKey: string; /** Defaults to `owner` for same-wallet battles. */ defenderOwner?: PublicKey; + /** Fires after commit tx confirms, while the oracle is fulfilling randomness. */ + onCommitted?: () => void; }; /** Completes a battle whose commit phase succeeded but settle was never submitted. */ const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { - const { program, provider, programId, owner } = args; + const { program, provider, programId, owner, onCommitted } = args; const connection = provider.connection; const [battleRequestKey] = battleRequestPda(programId, owner); const pending = await getAccountClient(program, 'battleRequest').fetchNullable(battleRequestKey); @@ -92,6 +94,7 @@ const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise void; }; /** Completes a breed whose commit phase succeeded but settle was never submitted. */ const trySettlePendingBreed = async (args: BreedWithVrfArgs): Promise => { - const { program, provider, programId, owner } = args; + const { program, provider, programId, owner, onCommitted } = args; const connection = provider.connection; const [breedRequestKey] = breedRequestPda(programId, owner); const pending = await getAccountClient(program, 'breedRequest').fetchNullable(breedRequestKey); @@ -89,6 +91,7 @@ const trySettlePendingBreed = async (args: BreedWithVrfArgs): Promise Date: Fri, 19 Jun 2026 15:14:10 -0400 Subject: [PATCH 07/48] feat: add pending VRF recovery notices for battle and breed --- .../panels/battle/parts/battle-setup.tsx | 2 +- .../battle/parts/pending-battle-notice.tsx | 30 +++++++++++--- .../pet/interactions/panels/breed/index.tsx | 2 +- .../panels/breed/pending-breed-notice.tsx | 29 ++++++++++--- .../chains/solana/usePendingSolanaBattle.ts | 41 +++++++++++++++++++ .../chains/solana/usePendingSolanaBreed.ts | 41 +++++++++++++++++++ shared/src/hooks/index.ts | 3 ++ 7 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 shared/src/hooks/chains/solana/usePendingSolanaBattle.ts create mode 100644 shared/src/hooks/chains/solana/usePendingSolanaBreed.ts diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index 2b8b9f94..3aa52b6d 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -116,7 +116,7 @@ const BattleSetup: React.FC = ({ - + {opponent ? : null}
diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx index 23a701c1..c81461ef 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { usePendingBattle } from '@shared/core'; +import { usePendingBattle, usePendingSolanaBattle } from '@shared/core'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; type PendingBattleNoticeProps = { @@ -7,23 +7,41 @@ type PendingBattleNoticeProps = { petId?: string; /** Friendly label for the pet (e.g. its name); falls back to the id. */ label?: string; + /** + * When true, also checks if the current Solana wallet has a pending battle + * request. Pass only on the fighter's notice (not the opponent's) to avoid + * duplicate banners — Solana pending is per-wallet, not per-pet. + */ + checkSolana?: boolean; }; /** * Recovery banner for an interrupted async battle. v2 battle is request → VRF → * settle; if the settle tx never lands, the pet stays pending and can't start a - * new battle. This lets the player resolve it from the UI: Settle once VRF has - * fulfilled, or Cancel beforehand (requester/owner only). + * new battle. EVM: Settle once VRF has fulfilled, or Cancel beforehand. + * Solana: recovery is automatic on the next battle attempt. */ -const PendingBattleNotice: React.FC = ({ petId, label }) => { +const PendingBattleNotice: React.FC = ({ petId, label, checkSolana = false }) => { const pending = usePendingBattle(petId); + const solanaPending = usePendingSolanaBattle(checkSolana); useTxErrorToast(pending.settle.error ?? pending.cancel.error); - if (!pending.isPending) return null; + if (!pending.isPending && !solanaPending.isPending) return null; const who = label ?? `#${petId}`; - const busy = pending.settle.isPending || pending.cancel.isPending; + if (solanaPending.isPending && !pending.isPending) { + return ( +
+

+ You have an unresolved battle on Solana. Starting a new battle will + resume it automatically. +

+
+ ); + } + + const busy = pending.settle.isPending || pending.cancel.isPending; return (

diff --git a/frontend/src/components/pet/interactions/panels/breed/index.tsx b/frontend/src/components/pet/interactions/panels/breed/index.tsx index 59b750cb..14486683 100644 --- a/frontend/src/components/pet/interactions/panels/breed/index.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/index.tsx @@ -261,7 +261,7 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { )} {!breed.isAwaitingFulfillment && ( <> - + )} diff --git a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx index 80336577..345cd9c0 100644 --- a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { usePendingBreed } from '@shared/core'; +import { usePendingBreed, usePendingSolanaBreed } from '@shared/core'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; type PendingBreedNoticeProps = { @@ -7,23 +7,40 @@ type PendingBreedNoticeProps = { petId?: string; /** Friendly label for the pet (e.g. its name); falls back to the id. */ label?: string; + /** + * When true, also checks if the current Solana wallet has a pending breed + * request. Pass only once per breed panel to avoid duplicate banners. + */ + checkSolana?: boolean; }; /** * Recovery banner for an interrupted async breed. v2 breed is request → VRF → * settle; if the settle tx never lands, the parents stay pending and can't breed - * again. This lets the player resolve it: Settle once VRF has fulfilled (mints - * the offspring), or Cancel beforehand. + * again. EVM: Settle once VRF has fulfilled, or Cancel beforehand. + * Solana: recovery is automatic on the next breed attempt. */ -const PendingBreedNotice: React.FC = ({ petId, label }) => { +const PendingBreedNotice: React.FC = ({ petId, label, checkSolana = false }) => { const pending = usePendingBreed(petId); + const solanaPending = usePendingSolanaBreed(checkSolana); useTxErrorToast(pending.settle.error ?? pending.cancel.error); - if (!pending.isPending) return null; + if (!pending.isPending && !solanaPending.isPending) return null; const who = label ?? `#${petId}`; - const busy = pending.settle.isPending || pending.cancel.isPending; + if (solanaPending.isPending && !pending.isPending) { + return ( +

+

+ You have an unresolved breed on Solana. Starting a new breed will + resume it and mint the offspring automatically. +

+
+ ); + } + + const busy = pending.settle.isPending || pending.cancel.isPending; return (

diff --git a/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts new file mode 100644 index 00000000..601c9f98 --- /dev/null +++ b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useProgram } from './useProgram'; +import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; +import { battleRequestPda } from '../../../utils/solana/pdas'; +import { getAccountClient } from '../../../utils/solana/accountClient'; + +export interface PendingSolanaBattle { + /** True when the current wallet has an unresolved on-chain battle request. */ + isPending: boolean; + refetch(): void; +} + +/** + * Checks whether the current Solana wallet has an open battle request PDA. + * Recovery is automatic — battleWithSwitchboardVrf resumes on the next battle + * attempt. This hook is used only to surface the pending state in the UI. + */ +export const usePendingSolanaBattle = (enabled = true): PendingSolanaBattle => { + const { signingWallet } = useSolanaAnchor(); + const { program, programId, isReady } = useProgram(); + const owner = signingWallet?.publicKey; + + const query = useQuery({ + queryKey: ['cryptopets', 'battleRequest', owner?.toBase58(), programId?.toBase58()], + enabled: enabled && isReady && Boolean(owner && program && programId), + queryFn: async () => { + if (!program || !programId || !owner) return null; + const [pda] = battleRequestPda(programId, owner); + return getAccountClient(program, 'battleRequest').fetchNullable(pda); + }, + refetchInterval: 5_000, + }); + + const refetch = useCallback(() => { void query.refetch(); }, [query]); + + return { + isPending: query.data != null, + refetch, + }; +}; diff --git a/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts b/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts new file mode 100644 index 00000000..33324a2f --- /dev/null +++ b/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useProgram } from './useProgram'; +import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; +import { breedRequestPda } from '../../../utils/solana/pdas'; +import { getAccountClient } from '../../../utils/solana/accountClient'; + +export interface PendingSolanaBreed { + /** True when the current wallet has an unresolved on-chain breed request. */ + isPending: boolean; + refetch(): void; +} + +/** + * Checks whether the current Solana wallet has an open breed request PDA. + * Recovery is automatic — breedWithSwitchboardVrf resumes on the next breed + * attempt. This hook is used only to surface the pending state in the UI. + */ +export const usePendingSolanaBreed = (enabled = true): PendingSolanaBreed => { + const { signingWallet } = useSolanaAnchor(); + const { program, programId, isReady } = useProgram(); + const owner = signingWallet?.publicKey; + + const query = useQuery({ + queryKey: ['cryptopets', 'breedRequest', owner?.toBase58(), programId?.toBase58()], + enabled: enabled && isReady && Boolean(owner && program && programId), + queryFn: async () => { + if (!program || !programId || !owner) return null; + const [pda] = breedRequestPda(programId, owner); + return getAccountClient(program, 'breedRequest').fetchNullable(pda); + }, + refetchInterval: 5_000, + }); + + const refetch = useCallback(() => { void query.refetch(); }, [query]); + + return { + isPending: query.data != null, + refetch, + }; +}; diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 083409de..10c46a5e 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -10,6 +10,9 @@ export { useSolanaFees, type SolanaFees } from './chains/solana/useSolanaFees'; // Manual recovery for an interrupted async battle (settle / cancel a pending request). export { usePendingBattle, type PendingBattle } from './chains/ethereum/usePendingBattle'; export { usePendingBreed, type PendingBreed } from './chains/ethereum/usePendingBreed'; +// Solana pending VRF requests — auto-resumed on next action, hooks are for UI notice only. +export { usePendingSolanaBattle, type PendingSolanaBattle } from './chains/solana/usePendingSolanaBattle'; +export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/usePendingSolanaBreed'; // v2.1 marriage: write actions + per-pet marriage state (EVM + Solana). export { useMarriage, type MarriageAction } from './useMarriage'; export { useMarriageInfo, type MarriageInfo } from './useMarriageInfo'; From a7a08754b48e5b810dbd8272d7c53d71330ad449 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:21:22 -0400 Subject: [PATCH 08/48] feat: add openToChallenges field, toggle action, and UI --- .../panels/battle/parts/battle-setup.tsx | 2 + .../parts/open-to-challenges-toggle.tsx | 36 +++++++++++++++++ .../src/hooks/chains/solana/usePetActions.ts | 14 +++++++ shared/src/hooks/index.ts | 2 + shared/src/hooks/useSetOpenToChallenges.ts | 39 +++++++++++++++++++ shared/src/types/pet.ts | 2 + shared/src/utils/pets/mapSolanaPet.ts | 1 + shared/tests/utils/pets/mapSolanaPet.test.ts | 9 +++++ 8 files changed, 105 insertions(+) create mode 100644 frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx create mode 100644 shared/src/hooks/useSetOpenToChallenges.ts diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index 3aa52b6d..a9274940 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -7,6 +7,7 @@ import ArenaSlot from './arena-slot'; import FighterPickerCard from './fighter-picker-card'; import OpponentPickerCard from './opponent-picker-card'; import PendingBattleNotice from './pending-battle-notice'; +import OpenToChallengesToggle from './open-to-challenges-toggle'; import { opponentKey, shortAddress } from '../battle-utils'; export type BattleSetupProps = { @@ -118,6 +119,7 @@ const BattleSetup: React.FC = ({ {opponent ? : null} +

diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx new file mode 100644 index 00000000..c34aa8e7 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/battle/parts/open-to-challenges-toggle.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useChainCapabilities, useSetOpenToChallenges } from '@shared/core'; +import { useTxErrorToast } from '@hooks/useTxErrorToast'; + +type OpenToChallengesToggleProps = { + /** The selected fighter's pet id. */ + petId?: string; + /** Current openToChallenges value from the mapped Pet. undefined = not loaded yet. */ + currentValue?: boolean; +}; + +/** + * Lets the owner opt their Solana pet in or out of being targeted as a + * defender. Only rendered on Solana — EVM has no defender consent. + */ +const OpenToChallengesToggle: React.FC = ({ petId, currentValue }) => { + const { activeKind } = useChainCapabilities(); + const { toggle, isPending, error } = useSetOpenToChallenges(); + useTxErrorToast(error); + + if (activeKind !== 'solana' || !petId || currentValue === undefined) return null; + + return ( + + ); +}; + +export default OpenToChallengesToggle; diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index 9083ed4f..224fbc6a 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -111,6 +111,19 @@ export const usePetActions = () => { onSuccess: invalidateProgramQueries, }); + const setOpenToChallenges = useMutation({ + mutationFn: async (args: { petId: number; assetKey: string; value: boolean }) => { + const { program, programId, owner } = requireReady(); + const petAsset = new PublicKey(args.assetKey); + const [pet] = petPdaByAsset(programId, args.assetKey); + return program.methods + .setOpenToChallenges(args.value) + .accounts({ petAsset, pet, owner }) + .rpc(); + }, + onSuccess: invalidateProgramQueries, + }); + /** * Battle the signer's pet (attacker) against any pet. When `defenderOwner` is * omitted it defaults to the signer (same-wallet battle); pass a foreign owner @@ -192,6 +205,7 @@ export const usePetActions = () => { levelUpPet, trainPet, renamePet, + setOpenToChallenges, battlePets, battleSubPhase, breedPets, diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 10c46a5e..e4db4dc6 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -13,6 +13,8 @@ export { usePendingBreed, type PendingBreed } from './chains/ethereum/usePending // Solana pending VRF requests — auto-resumed on next action, hooks are for UI notice only. export { usePendingSolanaBattle, type PendingSolanaBattle } from './chains/solana/usePendingSolanaBattle'; export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/usePendingSolanaBreed'; +// Solana defender-consent toggle (openToChallenges). No-op on EVM. +export { useSetOpenToChallenges, type UseSetOpenToChallengesResult } from './useSetOpenToChallenges'; // v2.1 marriage: write actions + per-pet marriage state (EVM + Solana). export { useMarriage, type MarriageAction } from './useMarriage'; export { useMarriageInfo, type MarriageInfo } from './useMarriageInfo'; diff --git a/shared/src/hooks/useSetOpenToChallenges.ts b/shared/src/hooks/useSetOpenToChallenges.ts new file mode 100644 index 00000000..58a7f87a --- /dev/null +++ b/shared/src/hooks/useSetOpenToChallenges.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useChainCapabilities } from './useChainCapabilities'; +import { usePetActions } from './chains/solana/usePetActions'; +import { usePetList } from './usePetList'; + +export interface UseSetOpenToChallengesResult { + /** Toggle the pet's open-to-challenges flag. No-op on EVM. */ + toggle(petId: string, currentValue: boolean): Promise; + isPending: boolean; + error: Error | null; +} + +/** + * Wraps the Solana `setOpenToChallenges` program instruction. + * Looks up the pet's asset key from the local pet list. No-op on EVM — + * EVM has no defender consent; all pets are challengeable by default. + */ +export const useSetOpenToChallenges = (): UseSetOpenToChallengesResult => { + const { activeKind } = useChainCapabilities(); + const actions = usePetActions(); + const { pets } = usePetList(); + + const toggle = useCallback(async (petId: string, currentValue: boolean) => { + if (activeKind !== 'solana') return; + const pet = pets.find((p) => p.id === petId); + if (!pet?.assetKey) throw new Error(`Asset key not found for pet #${petId} — refresh and retry`); + await actions.setOpenToChallenges.mutateAsync({ + petId: Number(petId), + assetKey: pet.assetKey, + value: !currentValue, + }); + }, [activeKind, pets, actions.setOpenToChallenges]); + + return { + toggle, + isPending: actions.setOpenToChallenges.isPending, + error: actions.setOpenToChallenges.error as Error | null, + }; +}; diff --git a/shared/src/types/pet.ts b/shared/src/types/pet.ts index ddeabbed..43a63d70 100644 --- a/shared/src/types/pet.ts +++ b/shared/src/types/pet.ts @@ -38,6 +38,8 @@ export interface Pet { spouseId?: number; /** Unix seconds until this pet may remarry after a divorce. Solana only. */ marriageCooldownUntil?: number; + /** Whether this pet can be targeted as a defender. Solana only; EVM has no defender consent. */ + openToChallenges?: boolean; } /** diff --git a/shared/src/utils/pets/mapSolanaPet.ts b/shared/src/utils/pets/mapSolanaPet.ts index 14294230..dc00a70e 100644 --- a/shared/src/utils/pets/mapSolanaPet.ts +++ b/shared/src/utils/pets/mapSolanaPet.ts @@ -74,5 +74,6 @@ export const mapSolanaPet = (row: SolanaPetAccountRow): Pet => { trainReadyAt: toNumber(a.trainReadyTime) || undefined, spouseId: spouseId !== 0 ? spouseId : undefined, marriageCooldownUntil: toNumber(a.marriageCooldownUntil) || undefined, + openToChallenges: typeof a.openToChallenges === 'boolean' ? a.openToChallenges : undefined, }; } diff --git a/shared/tests/utils/pets/mapSolanaPet.test.ts b/shared/tests/utils/pets/mapSolanaPet.test.ts index 3a103245..364e7568 100644 --- a/shared/tests/utils/pets/mapSolanaPet.test.ts +++ b/shared/tests/utils/pets/mapSolanaPet.test.ts @@ -117,4 +117,13 @@ describe('mapSolanaPet', () => { expect(pet.readyAt).toBe(0); expect(pet.name).toBe(''); }); + + it('maps openToChallenges boolean', () => { + expect(mapSolanaPet(row({ openToChallenges: true })).openToChallenges).toBe(true); + expect(mapSolanaPet(row({ openToChallenges: false })).openToChallenges).toBe(false); + }); + + it('omits openToChallenges when absent from account', () => { + expect(mapSolanaPet(row({})).openToChallenges).toBeUndefined(); + }); }); From b6015f311a11eb050193efaa398b19efc25066e0 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:28:35 -0400 Subject: [PATCH 09/48] feat: add cancel_battle and cancel_breed support --- .../battle/parts/pending-battle-notice.tsx | 16 +++- .../panels/breed/pending-breed-notice.tsx | 16 +++- .../chains/solana/usePendingSolanaBattle.ts | 71 +++++++++++++--- .../chains/solana/usePendingSolanaBreed.ts | 83 ++++++++++++++++--- shared/src/hooks/index.ts | 2 +- 5 files changed, 159 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx index c81461ef..93b8fd09 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/pending-battle-notice.tsx @@ -24,19 +24,29 @@ type PendingBattleNoticeProps = { const PendingBattleNotice: React.FC = ({ petId, label, checkSolana = false }) => { const pending = usePendingBattle(petId); const solanaPending = usePendingSolanaBattle(checkSolana); - useTxErrorToast(pending.settle.error ?? pending.cancel.error); + useTxErrorToast(pending.settle.error ?? pending.cancel.error ?? solanaPending.cancel.error); if (!pending.isPending && !solanaPending.isPending) return null; const who = label ?? `#${petId}`; if (solanaPending.isPending && !pending.isPending) { + const busy = solanaPending.cancel.isPending; return (

- You have an unresolved battle on Solana. Starting a new battle will - resume it automatically. + You have an unresolved battle on Solana. + {solanaPending.canCancel + ? ' Randomness has expired — cancel to free the pet for a new battle.' + : ' Starting a new battle will resume it automatically.'}

+ {solanaPending.canCancel && ( +
+ +
+ )}
); } diff --git a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx index 345cd9c0..da3ac933 100644 --- a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx @@ -23,19 +23,29 @@ type PendingBreedNoticeProps = { const PendingBreedNotice: React.FC = ({ petId, label, checkSolana = false }) => { const pending = usePendingBreed(petId); const solanaPending = usePendingSolanaBreed(checkSolana); - useTxErrorToast(pending.settle.error ?? pending.cancel.error); + useTxErrorToast(pending.settle.error ?? pending.cancel.error ?? solanaPending.cancel.error); if (!pending.isPending && !solanaPending.isPending) return null; const who = label ?? `#${petId}`; if (solanaPending.isPending && !pending.isPending) { + const busy = solanaPending.cancel.isPending; return (

- You have an unresolved breed on Solana. Starting a new breed will - resume it and mint the offspring automatically. + You have an unresolved breed on Solana. + {solanaPending.canCancel + ? ' Randomness has expired — cancel to free the parents for a new breed.' + : ' Starting a new breed will resume it and mint the offspring automatically.'}

+ {solanaPending.canCancel && ( +
+ +
+ )}
); } diff --git a/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts index 601c9f98..c83fef9e 100644 --- a/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts +++ b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts @@ -1,41 +1,90 @@ import { useCallback } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useProgram } from './useProgram'; import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; -import { battleRequestPda } from '../../../utils/solana/pdas'; +import { battleRequestPda, globalStatePda } from '../../../utils/solana/pdas'; import { getAccountClient } from '../../../utils/solana/accountClient'; export interface PendingSolanaBattle { /** True when the current wallet has an unresolved on-chain battle request. */ isPending: boolean; + /** + * True when the randomness has expired and cancel_battle can be called. + * Always false until the slot data has loaded. + */ + canCancel: boolean; + cancel: { + run(): Promise; + isPending: boolean; + error: Error | null; + }; refetch(): void; } -/** - * Checks whether the current Solana wallet has an open battle request PDA. - * Recovery is automatic — battleWithSwitchboardVrf resumes on the next battle - * attempt. This hook is used only to surface the pending state in the UI. - */ +const toNumber = (v: unknown): number => { + if (typeof v === 'number') return v; + if (typeof v === 'bigint') return Number(v); + if (v && typeof (v as { toString(): string }).toString === 'function') return Number((v as { toString(): string }).toString()); + return 0; +}; + export const usePendingSolanaBattle = (enabled = true): PendingSolanaBattle => { - const { signingWallet } = useSolanaAnchor(); + const { signingWallet, connection } = useSolanaAnchor(); const { program, programId, isReady } = useProgram(); const owner = signingWallet?.publicKey; + const queryClient = useQueryClient(); + + const queryKey = ['cryptopets', 'battleRequest', owner?.toBase58(), programId?.toBase58()]; const query = useQuery({ - queryKey: ['cryptopets', 'battleRequest', owner?.toBase58(), programId?.toBase58()], + queryKey, enabled: enabled && isReady && Boolean(owner && program && programId), queryFn: async () => { if (!program || !programId || !owner) return null; const [pda] = battleRequestPda(programId, owner); - return getAccountClient(program, 'battleRequest').fetchNullable(pda); + const request = await getAccountClient(program, 'battleRequest').fetchNullable(pda); + if (!request) return null; + const [gsPda] = globalStatePda(programId); + const gs = await getAccountClient(program, 'globalState').fetchNullable(gsPda) as Record | null; + const currentSlot = await connection.getSlot('confirmed'); + const req = request as Record; + const commitSlot = toNumber(req.commitSlot); + const expirySlots = gs ? toNumber(gs.randomnessExpirySlots) : 0; + return { request: req, commitSlot, expirySlots, currentSlot }; }, refetchInterval: 5_000, }); + const isPending = query.data != null; + const canCancel = isPending && query.data != null + ? query.data.currentSlot > query.data.commitSlot + query.data.expirySlots + : false; + + const cancelMutation = useMutation({ + mutationFn: async () => { + if (!program || !programId || !owner) throw new Error('Solana program not ready'); + const [globalState] = globalStatePda(programId); + const [battleRequest] = battleRequestPda(programId, owner); + await program.methods + .cancelBattle() + .accounts({ globalState, attackerOwner: owner, battleRequest }) + .rpc(); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + const refetch = useCallback(() => { void query.refetch(); }, [query]); return { - isPending: query.data != null, + isPending, + canCancel, + cancel: { + run: cancelMutation.mutateAsync, + isPending: cancelMutation.isPending, + error: cancelMutation.error as Error | null, + }, refetch, }; }; diff --git a/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts b/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts index 33324a2f..d7741a61 100644 --- a/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts +++ b/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts @@ -1,41 +1,102 @@ import { useCallback } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useProgram } from './useProgram'; import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; -import { breedRequestPda } from '../../../utils/solana/pdas'; +import { breedRequestPda, globalStatePda, studFeeAccountPda } from '../../../utils/solana/pdas'; import { getAccountClient } from '../../../utils/solana/accountClient'; +import { PublicKey } from '@solana/web3.js'; export interface PendingSolanaBreed { /** True when the current wallet has an unresolved on-chain breed request. */ isPending: boolean; + /** + * True when the randomness has expired and cancel_breed can be called. + * Always false until the slot data has loaded. + */ + canCancel: boolean; + cancel: { + run(): Promise; + isPending: boolean; + error: Error | null; + }; refetch(): void; } -/** - * Checks whether the current Solana wallet has an open breed request PDA. - * Recovery is automatic — breedWithSwitchboardVrf resumes on the next breed - * attempt. This hook is used only to surface the pending state in the UI. - */ +const toNumber = (v: unknown): number => { + if (typeof v === 'number') return v; + if (typeof v === 'bigint') return Number(v); + if (v && typeof (v as { toString(): string }).toString === 'function') return Number((v as { toString(): string }).toString()); + return 0; +}; + +const toPublicKey = (v: unknown): PublicKey | null => { + try { + if (v instanceof PublicKey) return v; + if (v && typeof (v as { toBase58(): string }).toBase58 === 'function') return new PublicKey((v as { toBase58(): string }).toBase58()); + return null; + } catch { return null; } +}; + export const usePendingSolanaBreed = (enabled = true): PendingSolanaBreed => { - const { signingWallet } = useSolanaAnchor(); + const { signingWallet, connection } = useSolanaAnchor(); const { program, programId, isReady } = useProgram(); const owner = signingWallet?.publicKey; + const queryClient = useQueryClient(); + + const queryKey = ['cryptopets', 'breedRequest', owner?.toBase58(), programId?.toBase58()]; const query = useQuery({ - queryKey: ['cryptopets', 'breedRequest', owner?.toBase58(), programId?.toBase58()], + queryKey, enabled: enabled && isReady && Boolean(owner && program && programId), queryFn: async () => { if (!program || !programId || !owner) return null; const [pda] = breedRequestPda(programId, owner); - return getAccountClient(program, 'breedRequest').fetchNullable(pda); + const request = await getAccountClient(program, 'breedRequest').fetchNullable(pda); + if (!request) return null; + const [gsPda] = globalStatePda(programId); + const gs = await getAccountClient(program, 'globalState').fetchNullable(gsPda) as Record | null; + const currentSlot = await connection.getSlot('confirmed'); + const req = request as Record; + const commitSlot = toNumber(req.commitSlot); + const expirySlots = gs ? toNumber(gs.randomnessExpirySlots) : 0; + const otherOwner = toPublicKey(req.otherOwner); + return { request: req, commitSlot, expirySlots, currentSlot, otherOwner }; }, refetchInterval: 5_000, }); + const isPending = query.data != null; + const canCancel = isPending && query.data != null + ? query.data.currentSlot > query.data.commitSlot + query.data.expirySlots + : false; + + const cancelMutation = useMutation({ + mutationFn: async () => { + if (!program || !programId || !owner) throw new Error('Solana program not ready'); + const otherOwner = query.data?.otherOwner ?? owner; + const [globalState] = globalStatePda(programId); + const [breedRequest] = breedRequestPda(programId, owner); + const [studFeeAccount] = studFeeAccountPda(programId, otherOwner); + await program.methods + .cancelBreed() + .accounts({ globalState, owner, breedRequest, studFeeAccount }) + .rpc(); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); + const refetch = useCallback(() => { void query.refetch(); }, [query]); return { - isPending: query.data != null, + isPending, + canCancel, + cancel: { + run: cancelMutation.mutateAsync, + isPending: cancelMutation.isPending, + error: cancelMutation.error as Error | null, + }, refetch, }; }; diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index e4db4dc6..3000f0d2 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -10,7 +10,7 @@ export { useSolanaFees, type SolanaFees } from './chains/solana/useSolanaFees'; // Manual recovery for an interrupted async battle (settle / cancel a pending request). export { usePendingBattle, type PendingBattle } from './chains/ethereum/usePendingBattle'; export { usePendingBreed, type PendingBreed } from './chains/ethereum/usePendingBreed'; -// Solana pending VRF requests — auto-resumed on next action, hooks are for UI notice only. +// Solana pending VRF requests — auto-resumes on next action; cancel available after randomness expiry. export { usePendingSolanaBattle, type PendingSolanaBattle } from './chains/solana/usePendingSolanaBattle'; export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/usePendingSolanaBreed'; // Solana defender-consent toggle (openToChallenges). No-op on EVM. From d153e9ca3e6a443605219f3687b7454d6f3ece1a Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:32:55 -0400 Subject: [PATCH 10/48] feat: add sync_metadata instruction support --- .../interactions/panels/level-up/index.tsx | 4 ++ .../panels/level-up/sync-metadata-button.tsx | 34 +++++++++++++++++ .../src/hooks/chains/solana/usePetActions.ts | 28 ++++++++++++++ shared/src/hooks/index.ts | 2 + shared/src/hooks/useSyncMetadata.ts | 38 +++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 frontend/src/components/pet/interactions/panels/level-up/sync-metadata-button.tsx create mode 100644 shared/src/hooks/useSyncMetadata.ts diff --git a/frontend/src/components/pet/interactions/panels/level-up/index.tsx b/frontend/src/components/pet/interactions/panels/level-up/index.tsx index 1a314408..a4f624f0 100644 --- a/frontend/src/components/pet/interactions/panels/level-up/index.tsx +++ b/frontend/src/components/pet/interactions/panels/level-up/index.tsx @@ -12,6 +12,7 @@ import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; import Icon, { CheckIcon } from '@components/ui/icon'; import { Tones } from '@constants/tones'; +import SyncMetadataButton from './sync-metadata-button'; export type LevelUpPanelProps = { isStandaloneView?: boolean; @@ -24,9 +25,11 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true }) const [selectedPet, setSelectedPet] = useState(''); const [success, setSuccess] = useState(null); + const [leveledUpPetId, setLeveledUpPetId] = useState(null); // Settlement is lifecycle-driven (EVM: receipt confirmed; Solana: resolve). const handleLevelUpComplete = () => { + setLeveledUpPetId(selectedPet); setSuccess('Pet leveled up successfully!'); setSelectedPet(''); refetch(); @@ -116,6 +119,7 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true })
{success} +
)} diff --git a/frontend/src/components/pet/interactions/panels/level-up/sync-metadata-button.tsx b/frontend/src/components/pet/interactions/panels/level-up/sync-metadata-button.tsx new file mode 100644 index 00000000..55ed1b7b --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/level-up/sync-metadata-button.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useChainCapabilities, useSyncMetadata } from '@shared/core'; +import { useTxErrorToast } from '@hooks/useTxErrorToast'; + +type SyncMetadataButtonProps = { + /** Pet to sync; nothing renders on EVM or when petId is absent. */ + petId?: string; +}; + +/** + * Re-syncs a Solana pet's Metaplex Core NFT attributes from on-chain state. + * Most useful after level-up or battle wins where level/XP changed. + * Permissionless — the connected wallet just pays the tx fee. + */ +const SyncMetadataButton: React.FC = ({ petId }) => { + const { activeKind } = useChainCapabilities(); + const { sync, isPending, error } = useSyncMetadata(); + useTxErrorToast(error); + + if (activeKind !== 'solana' || !petId) return null; + + return ( + + ); +}; + +export default SyncMetadataButton; diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index 224fbc6a..dab997bb 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -10,6 +10,8 @@ import { import { battleWithSwitchboardVrf, type BattleVrfResult } from '../../../utils/solana/battleWithSwitchboardVrf'; import { breedWithSwitchboardVrf } from '../../../utils/solana/breedWithSwitchboardVrf'; import { mintWithSwitchboardVrf } from '../../../utils/solana/mintWithSwitchboardVrf'; +import { getAccountClient } from '../../../utils/solana/accountClient'; +import { MPL_CORE_PROGRAM_ID } from '../../../utils/solana/constants'; import { useProgram } from './useProgram'; export const usePetActions = () => { @@ -111,6 +113,31 @@ export const usePetActions = () => { onSuccess: invalidateProgramQueries, }); + const syncMetadata = useMutation({ + mutationFn: async (args: { assetKey: string }) => { + const { program, programId, owner } = requireReady(); + const petAsset = new PublicKey(args.assetKey); + const [pet] = petPdaByAsset(programId, args.assetKey); + const [globalState] = globalStatePda(programId); + const gs = (await getAccountClient(program, 'globalState').fetch(globalState)) as { collection: unknown }; + const collection = gs.collection instanceof PublicKey + ? gs.collection + : new PublicKey(String((gs.collection as { toBase58(): string }).toBase58?.() ?? gs.collection)); + return program.methods + .syncMetadata() + .accounts({ + globalState, + asset: petAsset, + pet, + mplCoreProgram: new PublicKey(MPL_CORE_PROGRAM_ID), + collection, + payer: owner, + systemProgram: SystemProgram.programId, + }) + .rpc(); + }, + }); + const setOpenToChallenges = useMutation({ mutationFn: async (args: { petId: number; assetKey: string; value: boolean }) => { const { program, programId, owner } = requireReady(); @@ -205,6 +232,7 @@ export const usePetActions = () => { levelUpPet, trainPet, renamePet, + syncMetadata, setOpenToChallenges, battlePets, battleSubPhase, diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 3000f0d2..0b75879f 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -15,6 +15,8 @@ export { usePendingSolanaBattle, type PendingSolanaBattle } from './chains/solan export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/usePendingSolanaBreed'; // Solana defender-consent toggle (openToChallenges). No-op on EVM. export { useSetOpenToChallenges, type UseSetOpenToChallengesResult } from './useSetOpenToChallenges'; +// Solana NFT metadata sync — re-publishes on-chain state to Metaplex Core attributes. No-op on EVM. +export { useSyncMetadata, type UseSyncMetadataResult } from './useSyncMetadata'; // v2.1 marriage: write actions + per-pet marriage state (EVM + Solana). export { useMarriage, type MarriageAction } from './useMarriage'; export { useMarriageInfo, type MarriageInfo } from './useMarriageInfo'; diff --git a/shared/src/hooks/useSyncMetadata.ts b/shared/src/hooks/useSyncMetadata.ts new file mode 100644 index 00000000..20966422 --- /dev/null +++ b/shared/src/hooks/useSyncMetadata.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; +import { useChainCapabilities } from './useChainCapabilities'; +import { usePetActions } from './chains/solana/usePetActions'; +import { usePetList } from './usePetList'; + +export interface UseSyncMetadataResult { + /** + * Re-publishes the pet's on-chain state to its Metaplex Core NFT attributes. + * No-op on EVM (NFT metadata is handled differently). Permissionless on Solana. + */ + sync(petId: string): Promise; + isPending: boolean; + error: Error | null; +} + +/** + * Wraps the Solana `syncMetadata` program instruction. + * Useful after level_up or battle wins where the pet's attributes change on-chain + * but the NFT metadata hasn't been updated yet. + */ +export const useSyncMetadata = (): UseSyncMetadataResult => { + const { activeKind } = useChainCapabilities(); + const actions = usePetActions(); + const { pets } = usePetList(); + + const sync = useCallback(async (petId: string) => { + if (activeKind !== 'solana') return; + const pet = pets.find((p) => p.id === petId); + if (!pet?.assetKey) throw new Error(`Asset key not found for pet #${petId} — refresh and retry`); + await actions.syncMetadata.mutateAsync({ assetKey: pet.assetKey }); + }, [activeKind, pets, actions.syncMetadata]); + + return { + sync, + isPending: actions.syncMetadata.isPending, + error: actions.syncMetadata.error as Error | null, + }; +}; From ace2352f40eeef977b08edc3e7e908b6fe72066b Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:36:24 -0400 Subject: [PATCH 11/48] feat: add stud fee balance reader and withdraw_stud_fees action --- .../pet/interactions/panels/breed/index.tsx | 3 + .../panels/breed/stud-fee-balance.tsx | 41 ++++++++++++ .../src/hooks/chains/solana/usePetActions.ts | 14 ++++ shared/src/hooks/index.ts | 2 + shared/src/hooks/useStudFees.ts | 67 +++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 frontend/src/components/pet/interactions/panels/breed/stud-fee-balance.tsx create mode 100644 shared/src/hooks/useStudFees.ts diff --git a/frontend/src/components/pet/interactions/panels/breed/index.tsx b/frontend/src/components/pet/interactions/panels/breed/index.tsx index 14486683..d638ce46 100644 --- a/frontend/src/components/pet/interactions/panels/breed/index.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/index.tsx @@ -18,6 +18,7 @@ import { AuthActionButton } from '@components/common'; import { formatTxHashHint } from '@hooks/usePetError'; import { usePetErrorToast } from '@hooks/usePetErrorToast'; import PendingBreedNotice from './pending-breed-notice'; +import StudFeeBalance from './stud-fee-balance'; import Icon, { CheckIcon, DnaIcon } from '@components/ui/icon'; import './index.css'; @@ -355,6 +356,8 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => {
)} + +
{ + const sol = Number(lamports) / Number(LAMPORTS_PER_SOL); + return `${sol.toFixed(sol < 0.01 ? 6 : 4)} SOL`; +}; + +/** + * Shows pending stud fee earnings for married Solana pets and a Withdraw button. + * Only rendered on Solana when the balance is non-zero. + */ +const StudFeeBalance: React.FC = () => { + const { activeKind } = useChainCapabilities(); + const { amountLamports, isLoading, withdraw } = useStudFees(); + useTxErrorToast(withdraw.error); + + if (activeKind !== 'solana') return null; + if (isLoading || amountLamports === null || amountLamports === 0n) return null; + + return ( +
+ + Stud fee earnings: {formatSol(amountLamports)} + + +
+ ); +}; + +export default StudFeeBalance; diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index dab997bb..e849ccad 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -6,6 +6,7 @@ import { feeVaultPda, globalStatePda, petPdaByAsset, + studFeeAccountPda, } from '../../../utils/solana/pdas'; import { battleWithSwitchboardVrf, type BattleVrfResult } from '../../../utils/solana/battleWithSwitchboardVrf'; import { breedWithSwitchboardVrf } from '../../../utils/solana/breedWithSwitchboardVrf'; @@ -113,6 +114,18 @@ export const usePetActions = () => { onSuccess: invalidateProgramQueries, }); + const withdrawStudFees = useMutation({ + mutationFn: async () => { + const { program, programId, owner } = requireReady(); + const [studFeeAccount] = studFeeAccountPda(programId, owner); + return program.methods + .withdrawStudFees() + .accounts({ owner, studFeeAccount }) + .rpc(); + }, + onSuccess: invalidateProgramQueries, + }); + const syncMetadata = useMutation({ mutationFn: async (args: { assetKey: string }) => { const { program, programId, owner } = requireReady(); @@ -232,6 +245,7 @@ export const usePetActions = () => { levelUpPet, trainPet, renamePet, + withdrawStudFees, syncMetadata, setOpenToChallenges, battlePets, diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 0b75879f..9560206c 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -17,6 +17,8 @@ export { usePendingSolanaBreed, type PendingSolanaBreed } from './chains/solana/ export { useSetOpenToChallenges, type UseSetOpenToChallengesResult } from './useSetOpenToChallenges'; // Solana NFT metadata sync — re-publishes on-chain state to Metaplex Core attributes. No-op on EVM. export { useSyncMetadata, type UseSyncMetadataResult } from './useSyncMetadata'; +// Solana stud fee earnings: balance query + withdraw_stud_fees action. +export { useStudFees, type UseStudFeesResult } from './useStudFees'; // v2.1 marriage: write actions + per-pet marriage state (EVM + Solana). export { useMarriage, type MarriageAction } from './useMarriage'; export { useMarriageInfo, type MarriageInfo } from './useMarriageInfo'; diff --git a/shared/src/hooks/useStudFees.ts b/shared/src/hooks/useStudFees.ts new file mode 100644 index 00000000..811d05b0 --- /dev/null +++ b/shared/src/hooks/useStudFees.ts @@ -0,0 +1,67 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useProgram } from './chains/solana/useProgram'; +import { useSolanaAnchor } from '../contexts/SolanaAnchorContext'; +import { studFeeAccountPda } from '../utils/solana/pdas'; +import { getAccountClient } from '../utils/solana/accountClient'; +import { usePetActions } from './chains/solana/usePetActions'; + +export interface UseStudFeesResult { + /** Withdrawable stud fee balance in lamports; null when not on Solana or not loaded. */ + amountLamports: bigint | null; + isLoading: boolean; + withdraw: { + run(): Promise; + isPending: boolean; + error: Error | null; + }; + refetch(): void; +} + +const toBigInt = (v: unknown): bigint => { + if (typeof v === 'bigint') return v; + try { return BigInt(String(v)); } catch { return 0n; } +}; + +/** + * Reads the current wallet's StudFeeAccount balance and exposes withdraw_stud_fees. + * Returns null balance when the account doesn't exist (not yet a stud provider). + */ +export const useStudFees = (): UseStudFeesResult => { + const { signingWallet } = useSolanaAnchor(); + const { program, programId, isReady } = useProgram(); + const actions = usePetActions(); + const owner = signingWallet?.publicKey; + + const queryKey = ['cryptopets', 'studFeeAccount', owner?.toBase58(), programId?.toBase58()]; + + const query = useQuery({ + queryKey, + enabled: isReady && Boolean(owner && program && programId), + queryFn: async () => { + if (!program || !programId || !owner) return null; + const [pda] = studFeeAccountPda(programId, owner); + const account = await getAccountClient(program, 'studFeeAccount').fetchNullable(pda); + if (!account) return null; + return account as Record; + }, + refetchInterval: 15_000, + }); + + const amountLamports = query.data != null + ? toBigInt((query.data as Record).amount) + : null; + + const refetch = useCallback(() => { void query.refetch(); }, [query]); + + return { + amountLamports, + isLoading: query.isLoading, + withdraw: { + run: actions.withdrawStudFees.mutateAsync, + isPending: actions.withdrawStudFees.isPending, + error: actions.withdrawStudFees.error as Error | null, + }, + refetch, + }; +}; From 98626a1a8a7a20088ce13bf33d2a71b31dd59a67 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 15:41:20 -0400 Subject: [PATCH 12/48] =?UTF-8?q?feat:=20mainnet=20readiness=20=E2=80=94?= =?UTF-8?q?=20cluster-aware=20VRF=20timing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/solana/battleWithSwitchboardVrf.ts | 14 +++++----- .../utils/solana/breedWithSwitchboardVrf.ts | 14 +++++----- .../utils/solana/mintWithSwitchboardVrf.ts | 14 +++++----- shared/src/utils/solana/switchboardVrfTx.ts | 18 ++++++++++++ shared/tests/utils/solana/vrfTiming.test.ts | 28 +++++++++++++++++++ 5 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 shared/tests/utils/solana/vrfTiming.test.ts diff --git a/shared/src/utils/solana/battleWithSwitchboardVrf.ts b/shared/src/utils/solana/battleWithSwitchboardVrf.ts index 3575abf2..16a8affe 100644 --- a/shared/src/utils/solana/battleWithSwitchboardVrf.ts +++ b/shared/src/utils/solana/battleWithSwitchboardVrf.ts @@ -6,9 +6,7 @@ import { battleRequestPda, globalStatePda, petPdaByAsset } from './pdas'; import { fetchAssetByPetId, getAccountClient } from './accountClient'; import { toU32 } from './numbers'; import { - COMMIT_REVEAL_WAIT_MS, - REVEAL_BACKOFF_MS, - REVEAL_RETRIES, + vrfTimingForEndpoint, sendSignedTx, waitForRevealIx, } from './switchboardVrfTx'; @@ -93,10 +91,11 @@ const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise { + if (rpcEndpoint.includes('mainnet')) { + return { commitRevealWaitMs: 5_000, revealRetries: 10, revealBackoffMs: 3_000 }; + } + return { commitRevealWaitMs: COMMIT_REVEAL_WAIT_MS, revealRetries: REVEAL_RETRIES, revealBackoffMs: REVEAL_BACKOFF_MS }; +}; + /** Switchboard VRF needs commit → (wait) → reveal; two wallet signatures is the minimum. */ const recentBlockhashFromTx = (tx: Transaction | VersionedTransaction): string | undefined => { diff --git a/shared/tests/utils/solana/vrfTiming.test.ts b/shared/tests/utils/solana/vrfTiming.test.ts new file mode 100644 index 00000000..0a2cb00f --- /dev/null +++ b/shared/tests/utils/solana/vrfTiming.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { vrfTimingForEndpoint, COMMIT_REVEAL_WAIT_MS, REVEAL_RETRIES, REVEAL_BACKOFF_MS } from '../../../src/utils/solana/switchboardVrfTx'; + +describe('vrfTimingForEndpoint', () => { + it('returns default devnet timing for devnet endpoint', () => { + const t = vrfTimingForEndpoint('https://api.devnet.solana.com'); + expect(t.commitRevealWaitMs).toBe(COMMIT_REVEAL_WAIT_MS); + expect(t.revealRetries).toBe(REVEAL_RETRIES); + expect(t.revealBackoffMs).toBe(REVEAL_BACKOFF_MS); + }); + + it('returns increased retries and wait for mainnet endpoint', () => { + const t = vrfTimingForEndpoint('https://api.mainnet-beta.solana.com'); + expect(t.commitRevealWaitMs).toBeGreaterThan(COMMIT_REVEAL_WAIT_MS); + expect(t.revealRetries).toBeGreaterThan(REVEAL_RETRIES); + expect(t.revealBackoffMs).toBeGreaterThanOrEqual(REVEAL_BACKOFF_MS); + }); + + it('returns default timing for localhost', () => { + const t = vrfTimingForEndpoint('http://localhost:8899'); + expect(t.revealRetries).toBe(REVEAL_RETRIES); + }); + + it('returns mainnet timing when endpoint contains mainnet', () => { + const t = vrfTimingForEndpoint('https://my-mainnet-rpc.example.com'); + expect(t.revealRetries).toBeGreaterThan(REVEAL_RETRIES); + }); +}); From 467a01d59e4d59e1482eeca39d09b16e07ccdeee Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 16:02:40 -0400 Subject: [PATCH 13/48] test: add tests for new Solana hooks and update adapter mock --- .../hooks/usePendingSolanaBattle.test.tsx | 126 ++++++++++++++++ .../hooks/usePendingSolanaBreed.test.tsx | 139 ++++++++++++++++++ .../hooks/useSetOpenToChallenges.test.tsx | 93 ++++++++++++ shared/tests/hooks/useSolanaAdapter.test.tsx | 10 ++ shared/tests/hooks/useStudFees.test.tsx | 107 ++++++++++++++ shared/tests/hooks/useSyncMetadata.test.tsx | 79 ++++++++++ 6 files changed, 554 insertions(+) create mode 100644 shared/tests/hooks/usePendingSolanaBattle.test.tsx create mode 100644 shared/tests/hooks/usePendingSolanaBreed.test.tsx create mode 100644 shared/tests/hooks/useSetOpenToChallenges.test.tsx create mode 100644 shared/tests/hooks/useStudFees.test.tsx create mode 100644 shared/tests/hooks/useSyncMetadata.test.tsx diff --git a/shared/tests/hooks/usePendingSolanaBattle.test.tsx b/shared/tests/hooks/usePendingSolanaBattle.test.tsx new file mode 100644 index 00000000..b647427a --- /dev/null +++ b/shared/tests/hooks/usePendingSolanaBattle.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import React from 'react'; + +// ---------- stubs ---------- +const owner = Keypair.generate().publicKey; +const programId = Keypair.generate().publicKey; + +const fetchNullable = vi.fn(); +const cancelBattleRpc = vi.fn().mockResolvedValue('cancel-sig'); +const getSlot = vi.fn().mockResolvedValue(1000); + +vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ + useSolanaAnchor: () => ({ + signingWallet: { publicKey: owner }, + connection: { getSlot }, + }), +})); + +const programStub: { program: unknown; programId: PublicKey | null; isReady: boolean } = { + program: { + methods: { + cancelBattle: () => ({ + accounts: () => ({ rpc: cancelBattleRpc }), + }), + }, + }, + programId, + isReady: true, +}; + +vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ + useProgram: () => programStub, +})); + +vi.mock('../../src/utils/solana/accountClient', () => ({ + getAccountClient: () => ({ fetchNullable }), +})); + +// Avoid calling PublicKey.findProgramAddressSync in jsdom (crypto compat issues) +const stubPda = (name: string) => [{ toBase58: () => `${name}11111111111111111` }, 255] as const; +vi.mock('../../src/utils/solana/pdas', () => ({ + battleRequestPda: () => stubPda('BattleReq'), + globalStatePda: () => stubPda('GlobalState'), +})); + +import { usePendingSolanaBattle } from '../../src/hooks/chains/solana/usePendingSolanaBattle'; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return React.createElement(QueryClientProvider, { client: qc }, children); +} + +beforeEach(() => { + vi.clearAllMocks(); + programStub.isReady = true; + programStub.programId = programId; + fetchNullable.mockResolvedValue(null); + getSlot.mockResolvedValue(1000); + cancelBattleRpc.mockResolvedValue('cancel-sig'); +}); + +describe('usePendingSolanaBattle', () => { + it('query is disabled when enabled=false', () => { + const { result } = renderHook(() => usePendingSolanaBattle(false), { wrapper }); + expect(result.current.isPending).toBe(false); + expect(fetchNullable).not.toHaveBeenCalled(); + }); + + it('query is disabled when program is not ready', () => { + programStub.isReady = false; + const { result } = renderHook(() => usePendingSolanaBattle(true), { wrapper }); + expect(result.current.isPending).toBe(false); + expect(fetchNullable).not.toHaveBeenCalled(); + }); + + it('isPending=false when battleRequest PDA is empty', async () => { + fetchNullable.mockResolvedValue(null); + const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(result.current.canCancel).toBe(false); + }); + + it('isPending=true when battleRequest exists', async () => { + fetchNullable + .mockResolvedValueOnce({ commitSlot: 900, randomnessAccount: PublicKey.default }) + .mockResolvedValue({ randomnessExpirySlots: 50 }); + const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); + await waitFor(() => expect(result.current.isPending).toBe(true)); + }); + + it('canCancel=false when slot has not exceeded commit+expiry', async () => { + // commitSlot=900, expirySlots=200 → expires at 1100; currentSlot=1000 < 1100 + fetchNullable + .mockResolvedValueOnce({ commitSlot: 900, randomnessAccount: PublicKey.default }) + .mockResolvedValue({ randomnessExpirySlots: 200 }); + getSlot.mockResolvedValue(1000); + const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); + await waitFor(() => expect(result.current.isPending).toBe(true)); + expect(result.current.canCancel).toBe(false); + }); + + it('canCancel=true when slot has exceeded commit+expiry', async () => { + // commitSlot=900, expirySlots=50 → expires at 950; currentSlot=1000 > 950 + fetchNullable + .mockResolvedValueOnce({ commitSlot: 900, randomnessAccount: PublicKey.default }) + .mockResolvedValue({ randomnessExpirySlots: 50 }); + getSlot.mockResolvedValue(1000); + const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); + await waitFor(() => expect(result.current.canCancel).toBe(true)); + }); + + it('cancel.isPending and cancel.error default to false/null', () => { + const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); + expect(result.current.cancel.isPending).toBe(false); + expect(result.current.cancel.error).toBeNull(); + }); + + it('exposes a refetch function', () => { + const { result } = renderHook(() => usePendingSolanaBattle(), { wrapper }); + expect(typeof result.current.refetch).toBe('function'); + }); +}); diff --git a/shared/tests/hooks/usePendingSolanaBreed.test.tsx b/shared/tests/hooks/usePendingSolanaBreed.test.tsx new file mode 100644 index 00000000..853955bd --- /dev/null +++ b/shared/tests/hooks/usePendingSolanaBreed.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Keypair, PublicKey } from '@solana/web3.js'; +import React from 'react'; + +// ---------- stubs ---------- +const owner = Keypair.generate().publicKey; +const programId = Keypair.generate().publicKey; + +const fetchNullable = vi.fn(); +const cancelBreedRpc = vi.fn().mockResolvedValue('cancel-sig'); +const getSlot = vi.fn().mockResolvedValue(1000); + +vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ + useSolanaAnchor: () => ({ + signingWallet: { publicKey: owner }, + connection: { getSlot }, + }), +})); + +const programStub: { program: unknown; programId: PublicKey | null; isReady: boolean } = { + program: { + methods: { + cancelBreed: () => ({ + accounts: () => ({ rpc: cancelBreedRpc }), + }), + }, + }, + programId, + isReady: true, +}; + +vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ + useProgram: () => programStub, +})); + +vi.mock('../../src/utils/solana/accountClient', () => ({ + getAccountClient: () => ({ fetchNullable }), +})); + +// Avoid calling PublicKey.findProgramAddressSync in jsdom (crypto compat issues) +const stubPda = (name: string) => [{ toBase58: () => `${name}11111111111111111` }, 255] as const; +vi.mock('../../src/utils/solana/pdas', () => ({ + breedRequestPda: () => stubPda('BreedReq'), + globalStatePda: () => stubPda('GlobalState'), + studFeeAccountPda: () => stubPda('StudFee'), +})); + +import { usePendingSolanaBreed } from '../../src/hooks/chains/solana/usePendingSolanaBreed'; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return React.createElement(QueryClientProvider, { client: qc }, children); +} + +beforeEach(() => { + vi.clearAllMocks(); + programStub.isReady = true; + programStub.programId = programId; + fetchNullable.mockResolvedValue(null); + getSlot.mockResolvedValue(1000); + cancelBreedRpc.mockResolvedValue('cancel-sig'); +}); + +describe('usePendingSolanaBreed', () => { + it('query is disabled when enabled=false', () => { + const { result } = renderHook(() => usePendingSolanaBreed(false), { wrapper }); + expect(result.current.isPending).toBe(false); + expect(fetchNullable).not.toHaveBeenCalled(); + }); + + it('query is disabled when program is not ready', () => { + programStub.isReady = false; + const { result } = renderHook(() => usePendingSolanaBreed(true), { wrapper }); + expect(result.current.isPending).toBe(false); + expect(fetchNullable).not.toHaveBeenCalled(); + }); + + it('isPending=false when breedRequest PDA is empty', async () => { + fetchNullable.mockResolvedValue(null); + const { result } = renderHook(() => usePendingSolanaBreed(), { wrapper }); + await waitFor(() => expect(result.current.isPending).toBe(false)); + expect(result.current.canCancel).toBe(false); + }); + + it('isPending=true when breedRequest exists', async () => { + fetchNullable + .mockResolvedValueOnce({ + commitSlot: 900, + otherOwner: PublicKey.default, + randomnessAccount: PublicKey.default, + }) + .mockResolvedValue({ randomnessExpirySlots: 50 }); + const { result } = renderHook(() => usePendingSolanaBreed(), { wrapper }); + await waitFor(() => expect(result.current.isPending).toBe(true)); + }); + + it('canCancel=false when slot has not exceeded commit+expiry', async () => { + // commitSlot=900, expirySlots=200 → expires at 1100; currentSlot=1000 < 1100 + fetchNullable + .mockResolvedValueOnce({ + commitSlot: 900, + otherOwner: PublicKey.default, + randomnessAccount: PublicKey.default, + }) + .mockResolvedValue({ randomnessExpirySlots: 200 }); + getSlot.mockResolvedValue(1000); + const { result } = renderHook(() => usePendingSolanaBreed(), { wrapper }); + await waitFor(() => expect(result.current.isPending).toBe(true)); + expect(result.current.canCancel).toBe(false); + }); + + it('canCancel=true when slot has exceeded commit+expiry', async () => { + // commitSlot=900, expirySlots=50 → expires at 950; currentSlot=1000 > 950 + fetchNullable + .mockResolvedValueOnce({ + commitSlot: 900, + otherOwner: PublicKey.default, + randomnessAccount: PublicKey.default, + }) + .mockResolvedValue({ randomnessExpirySlots: 50 }); + getSlot.mockResolvedValue(1000); + const { result } = renderHook(() => usePendingSolanaBreed(), { wrapper }); + await waitFor(() => expect(result.current.canCancel).toBe(true)); + }); + + it('cancel.isPending and cancel.error default to false/null', () => { + const { result } = renderHook(() => usePendingSolanaBreed(), { wrapper }); + expect(result.current.cancel.isPending).toBe(false); + expect(result.current.cancel.error).toBeNull(); + }); + + it('exposes a refetch function', () => { + const { result } = renderHook(() => usePendingSolanaBreed(), { wrapper }); + expect(typeof result.current.refetch).toBe('function'); + }); +}); diff --git a/shared/tests/hooks/useSetOpenToChallenges.test.tsx b/shared/tests/hooks/useSetOpenToChallenges.test.tsx new file mode 100644 index 00000000..7c81125d --- /dev/null +++ b/shared/tests/hooks/useSetOpenToChallenges.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// ---------- stubs ---------- +const setOpenToChallenges = { + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, + error: null as Error | null, +}; +const actions = { setOpenToChallenges }; + +let activeKind: string = 'solana'; + +vi.mock('../../src/hooks/useChainCapabilities', () => ({ + useChainCapabilities: () => ({ activeKind }), +})); +vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ + usePetActions: () => actions, +})); + +const testPets = [ + { id: '1', assetKey: 'asset-key-1', name: 'Alpha' }, + { id: '2', assetKey: undefined, name: 'NoKey' }, +]; +vi.mock('../../src/hooks/usePetList', () => ({ + usePetList: () => ({ pets: testPets }), +})); + +import { useSetOpenToChallenges } from '../../src/hooks/useSetOpenToChallenges'; + +beforeEach(() => { + vi.clearAllMocks(); + activeKind = 'solana'; + setOpenToChallenges.mutateAsync.mockResolvedValue(undefined); + setOpenToChallenges.isPending = false; + setOpenToChallenges.error = null; +}); + +describe('useSetOpenToChallenges', () => { + it('calls setOpenToChallenges.mutateAsync with inverted value on Solana', async () => { + const { result } = renderHook(() => useSetOpenToChallenges()); + await act(async () => { await result.current.toggle('1', false); }); + expect(setOpenToChallenges.mutateAsync).toHaveBeenCalledWith({ + petId: 1, + assetKey: 'asset-key-1', + value: true, + }); + }); + + it('inverts currentValue=true to false', async () => { + const { result } = renderHook(() => useSetOpenToChallenges()); + await act(async () => { await result.current.toggle('1', true); }); + expect(setOpenToChallenges.mutateAsync).toHaveBeenCalledWith({ + petId: 1, + assetKey: 'asset-key-1', + value: false, + }); + }); + + it('is a no-op on EVM chain', async () => { + activeKind = 'evm'; + const { result } = renderHook(() => useSetOpenToChallenges()); + await act(async () => { await result.current.toggle('1', false); }); + expect(setOpenToChallenges.mutateAsync).not.toHaveBeenCalled(); + }); + + it('throws when assetKey is not found', async () => { + const { result } = renderHook(() => useSetOpenToChallenges()); + await expect( + act(async () => { await result.current.toggle('2', false); }) + ).rejects.toThrow(/asset key not found/i); + }); + + it('throws when petId is unknown', async () => { + const { result } = renderHook(() => useSetOpenToChallenges()); + await expect( + act(async () => { await result.current.toggle('999', false); }) + ).rejects.toThrow(/asset key not found/i); + }); + + it('reflects isPending from actions', () => { + setOpenToChallenges.isPending = true; + const { result } = renderHook(() => useSetOpenToChallenges()); + expect(result.current.isPending).toBe(true); + }); + + it('reflects error from actions', () => { + setOpenToChallenges.error = new Error('tx failed'); + const { result } = renderHook(() => useSetOpenToChallenges()); + expect(result.current.error?.message).toBe('tx failed'); + }); +}); diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index a16f863b..06f42f62 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -30,6 +30,11 @@ const actions = { renamePet: makeMutation(), battlePets: makeMutation({ sig: 'settle-sig', firstWins: true }), breedPets: makeMutation(), + setOpenToChallenges: makeMutation(), + syncMetadata: makeMutation(), + withdrawStudFees: makeMutation(), + battleSubPhase: 'idle' as 'idle' | 'awaiting-vrf', + breedSubPhase: 'idle' as 'idle' | 'awaiting-vrf', }; const petsQuery = { data: testPets, isLoading: false, isFetching: false, error: null, refetch: vi.fn() }; const anchor = { signingWallet: { publicKey: Keypair.generate().publicKey } as { publicKey: unknown } | null }; @@ -64,8 +69,13 @@ beforeEach(() => { Object.assign(actions.renamePet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.battlePets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.breedPets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.setOpenToChallenges, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.syncMetadata, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.withdrawStudFees, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); actions.battlePets.mutateAsync.mockResolvedValue({ sig: 'settle-sig', firstWins: true }); actions.breedPets.mutateAsync.mockResolvedValue(undefined); + actions.battleSubPhase = 'idle'; + actions.breedSubPhase = 'idle'; anchor.signingWallet = { publicKey: Keypair.generate().publicKey }; }); diff --git a/shared/tests/hooks/useStudFees.test.tsx b/shared/tests/hooks/useStudFees.test.tsx new file mode 100644 index 00000000..361b59e3 --- /dev/null +++ b/shared/tests/hooks/useStudFees.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Keypair } from '@solana/web3.js'; +import React from 'react'; + +// ---------- stubs ---------- +const owner = Keypair.generate().publicKey; +const programId = Keypair.generate().publicKey; + +const fetchNullable = vi.fn(); +const withdrawStudFees = { + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, + error: null as Error | null, +}; + +vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ + useSolanaAnchor: () => ({ signingWallet: { publicKey: owner } }), +})); + +const programStub = { program: {}, programId, isReady: true }; +vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ + useProgram: () => programStub, +})); + +vi.mock('../../src/utils/solana/accountClient', () => ({ + getAccountClient: () => ({ fetchNullable }), +})); + +vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ + usePetActions: () => ({ withdrawStudFees }), +})); + +// Avoid calling PublicKey.findProgramAddressSync in jsdom (crypto compat issues) +const stubPda = [{ toBase58: () => 'StudFeePda11111111111111111' }, 255] as const; +vi.mock('../../src/utils/solana/pdas', () => ({ + studFeeAccountPda: () => stubPda, +})); + +import { useStudFees } from '../../src/hooks/useStudFees'; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return React.createElement(QueryClientProvider, { client: qc }, children); +} + +beforeEach(() => { + vi.clearAllMocks(); + programStub.isReady = true; + fetchNullable.mockResolvedValue(null); + withdrawStudFees.mutateAsync.mockResolvedValue(undefined); + withdrawStudFees.isPending = false; + withdrawStudFees.error = null; +}); + +describe('useStudFees', () => { + it('amountLamports=null when studFeeAccount does not exist', async () => { + fetchNullable.mockResolvedValue(null); + const { result } = renderHook(() => useStudFees(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.amountLamports).toBeNull(); + }); + + it('amountLamports equals the on-chain amount as bigint', async () => { + fetchNullable.mockResolvedValue({ amount: 500_000_000 }); + const { result } = renderHook(() => useStudFees(), { wrapper }); + await waitFor(() => expect(result.current.amountLamports).toBe(500_000_000n)); + }); + + it('converts bigint amount from on-chain', async () => { + fetchNullable.mockResolvedValue({ amount: 1_000_000_000n }); + const { result } = renderHook(() => useStudFees(), { wrapper }); + await waitFor(() => expect(result.current.amountLamports).toBe(1_000_000_000n)); + }); + + it('withdraw.run delegates to withdrawStudFees.mutateAsync', async () => { + const { result } = renderHook(() => useStudFees(), { wrapper }); + await result.current.withdraw.run(); + expect(withdrawStudFees.mutateAsync).toHaveBeenCalledOnce(); + }); + + it('withdraw.isPending reflects action state', () => { + withdrawStudFees.isPending = true; + const { result } = renderHook(() => useStudFees(), { wrapper }); + expect(result.current.withdraw.isPending).toBe(true); + }); + + it('withdraw.error reflects action error', () => { + withdrawStudFees.error = new Error('withdraw failed'); + const { result } = renderHook(() => useStudFees(), { wrapper }); + expect(result.current.withdraw.error?.message).toBe('withdraw failed'); + }); + + it('query is disabled when program is not ready', () => { + programStub.isReady = false; + const { result } = renderHook(() => useStudFees(), { wrapper }); + expect(result.current.isLoading).toBe(false); + expect(fetchNullable).not.toHaveBeenCalled(); + }); + + it('exposes a refetch function', () => { + const { result } = renderHook(() => useStudFees(), { wrapper }); + expect(typeof result.current.refetch).toBe('function'); + }); +}); diff --git a/shared/tests/hooks/useSyncMetadata.test.tsx b/shared/tests/hooks/useSyncMetadata.test.tsx new file mode 100644 index 00000000..57a800cd --- /dev/null +++ b/shared/tests/hooks/useSyncMetadata.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +// ---------- stubs ---------- +const syncMetadata = { + mutateAsync: vi.fn().mockResolvedValue(undefined), + isPending: false, + error: null as Error | null, +}; +const actions = { syncMetadata }; + +let activeKind: string = 'solana'; + +vi.mock('../../src/hooks/useChainCapabilities', () => ({ + useChainCapabilities: () => ({ activeKind }), +})); +vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ + usePetActions: () => actions, +})); + +const testPets = [ + { id: '5', assetKey: 'asset-key-5', name: 'LeveledUp' }, + { id: '7', assetKey: undefined, name: 'NoKey' }, +]; +vi.mock('../../src/hooks/usePetList', () => ({ + usePetList: () => ({ pets: testPets }), +})); + +import { useSyncMetadata } from '../../src/hooks/useSyncMetadata'; + +beforeEach(() => { + vi.clearAllMocks(); + activeKind = 'solana'; + syncMetadata.mutateAsync.mockResolvedValue(undefined); + syncMetadata.isPending = false; + syncMetadata.error = null; +}); + +describe('useSyncMetadata', () => { + it('calls syncMetadata.mutateAsync with assetKey on Solana', async () => { + const { result } = renderHook(() => useSyncMetadata()); + await act(async () => { await result.current.sync('5'); }); + expect(syncMetadata.mutateAsync).toHaveBeenCalledWith({ assetKey: 'asset-key-5' }); + }); + + it('is a no-op on EVM chain', async () => { + activeKind = 'evm'; + const { result } = renderHook(() => useSyncMetadata()); + await act(async () => { await result.current.sync('5'); }); + expect(syncMetadata.mutateAsync).not.toHaveBeenCalled(); + }); + + it('throws when assetKey is not found', async () => { + const { result } = renderHook(() => useSyncMetadata()); + await expect( + act(async () => { await result.current.sync('7'); }) + ).rejects.toThrow(/asset key not found/i); + }); + + it('throws when petId is unknown', async () => { + const { result } = renderHook(() => useSyncMetadata()); + await expect( + act(async () => { await result.current.sync('999'); }) + ).rejects.toThrow(/asset key not found/i); + }); + + it('reflects isPending from actions', () => { + syncMetadata.isPending = true; + const { result } = renderHook(() => useSyncMetadata()); + expect(result.current.isPending).toBe(true); + }); + + it('reflects error from actions', () => { + syncMetadata.error = new Error('sync failed'); + const { result } = renderHook(() => useSyncMetadata()); + expect(result.current.error?.message).toBe('sync failed'); + }); +}); From 1d728b5c2dc266b66ede83ff32174197fbb1a5f4 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Fri, 19 Jun 2026 16:37:45 -0400 Subject: [PATCH 14/48] fix: wire VITE_CRYPTOPETS_IDL_ADDRESS into Program.fetchIdl --- frontend/src/chains/solana/anchor-wallet.tsx | 7 ++++++- shared/src/contexts/SolanaAnchorContext.tsx | 7 ++++++- shared/src/hooks/chains/solana/useProgram.ts | 8 ++++++-- shared/tests/contexts.test.tsx | 1 + shared/tests/hooks/useSolanaAdapter.test.tsx | 2 +- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/src/chains/solana/anchor-wallet.tsx b/frontend/src/chains/solana/anchor-wallet.tsx index 459851ac..de0d9ad7 100644 --- a/frontend/src/chains/solana/anchor-wallet.tsx +++ b/frontend/src/chains/solana/anchor-wallet.tsx @@ -61,8 +61,13 @@ export const SolanaAnchorWallet = ({ children }: { children: ReactNode }) => { [] ); + const idlAddress = useMemo( + () => parseProgramId(import.meta.env.VITE_CRYPTOPETS_IDL_ADDRESS), + [] + ); + return ( - + {children} ); diff --git a/shared/src/contexts/SolanaAnchorContext.tsx b/shared/src/contexts/SolanaAnchorContext.tsx index 17d1ed38..509bc311 100644 --- a/shared/src/contexts/SolanaAnchorContext.tsx +++ b/shared/src/contexts/SolanaAnchorContext.tsx @@ -11,6 +11,8 @@ export type SolanaSigningWallet = { export type SolanaAnchorContextValue = { connection: Connection; programId: PublicKey | null; + /** Explicit IDL account address; overrides the PDA derived from programId in `useProgram`. */ + idlAddress: PublicKey | null; signingWallet: SolanaSigningWallet | null; }; @@ -28,6 +30,7 @@ export type SolanaAnchorProviderProps = { children: ReactNode; connection: Connection; programId: PublicKey | null; + idlAddress?: PublicKey | null; signingWallet: SolanaSigningWallet | null; }; @@ -35,15 +38,17 @@ export const SolanaAnchorProvider = ({ children, connection, programId, + idlAddress = null, signingWallet, }: SolanaAnchorProviderProps) => { const value = useMemo( (): SolanaAnchorContextValue => ({ connection, programId, + idlAddress, signingWallet, }), - [connection, programId, signingWallet] + [connection, programId, idlAddress, signingWallet] ); return {children}; diff --git a/shared/src/hooks/chains/solana/useProgram.ts b/shared/src/hooks/chains/solana/useProgram.ts index c692e342..87e34ce1 100644 --- a/shared/src/hooks/chains/solana/useProgram.ts +++ b/shared/src/hooks/chains/solana/useProgram.ts @@ -15,7 +15,7 @@ const READ_ONLY_WALLET: SolanaSigningWallet = { export type SolanaProgram = Program; export const useProgram = () => { - const { connection, programId, signingWallet } = useSolanaAnchor(); + const { connection, programId, idlAddress, signingWallet } = useSolanaAnchor(); const providerWallet = signingWallet ?? READ_ONLY_WALLET; @@ -34,6 +34,7 @@ export const useProgram = () => { 'program', connection.rpcEndpoint, programId?.toBase58() ?? 'none', + idlAddress?.toBase58() ?? 'derived', signingWallet?.publicKey?.toBase58() ?? 'read-only', ], enabled: programId !== null, @@ -41,7 +42,10 @@ export const useProgram = () => { if (!programId) { throw new Error('Solana program id is not configured'); } - const idl = await Program.fetchIdl(programId, provider); + // Use an explicit IDL account address when provided (VITE_CRYPTOPETS_IDL_ADDRESS), + // otherwise fall back to the PDA Anchor derives from the program id. + const fetchAddress = idlAddress ?? programId; + const idl = await Program.fetchIdl(fetchAddress, provider); if (!idl) { throw new Error( 'IDL not found on-chain for this program. Deploy the IDL (`anchor idl init`) or point RPC at a cluster where it exists.' diff --git a/shared/tests/contexts.test.tsx b/shared/tests/contexts.test.tsx index 12bbc49e..1d3ffaed 100644 --- a/shared/tests/contexts.test.tsx +++ b/shared/tests/contexts.test.tsx @@ -142,6 +142,7 @@ describe('SolanaAnchorContext', () => { expect(result.current).toEqual({ connection, programId, + idlAddress: null, signingWallet, }); }); diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index 06f42f62..18ba773b 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -42,7 +42,7 @@ const anchor = { signingWallet: { publicKey: Keypair.generate().publicKey } as { vi.mock('../../src/hooks/chains/solana/usePetActions', () => ({ usePetActions: () => actions })); vi.mock('../../src/hooks/chains/solana/usePets', () => ({ usePets: () => petsQuery })); vi.mock('../../src/contexts/SolanaAnchorContext', () => ({ - useSolanaAnchor: () => ({ ...anchor, connection: { rpcEndpoint: 'https://api.devnet.solana.com' } }), + useSolanaAnchor: () => ({ ...anchor, connection: { rpcEndpoint: 'https://api.devnet.solana.com' }, idlAddress: null }), })); vi.mock('../../src/hooks/chains/solana/useProgram', () => ({ useProgram: () => ({ program: null, programId: null, provider: null, isConfigured: false, isLoading: false, isFetching: false, error: null, refetch: vi.fn(), isReady: false }), From 7808f8d39772262fb7601d8c69075615c2a4351a Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 10:02:30 -0400 Subject: [PATCH 15/48] fix: split IDL fetch from Program instantiation to prevent re-fetch on wallet change --- shared/src/hooks/chains/solana/useProgram.ts | 46 ++++++++++--------- .../src/hooks/chains/solana/useSolanaFees.ts | 2 +- shared/tests/hooks/useSolanaFees.test.tsx | 4 +- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/shared/src/hooks/chains/solana/useProgram.ts b/shared/src/hooks/chains/solana/useProgram.ts index 87e34ce1..a33ec386 100644 --- a/shared/src/hooks/chains/solana/useProgram.ts +++ b/shared/src/hooks/chains/solana/useProgram.ts @@ -15,7 +15,7 @@ const READ_ONLY_WALLET: SolanaSigningWallet = { export type SolanaProgram = Program; export const useProgram = () => { - const { connection, programId, idlAddress, signingWallet } = useSolanaAnchor(); + const { connection, programId, signingWallet } = useSolanaAnchor(); const providerWallet = signingWallet ?? READ_ONLY_WALLET; @@ -28,42 +28,44 @@ export const useProgram = () => { [connection, providerWallet] ); - const query = useQuery({ - queryKey: [ - 'cryptopets', - 'program', - connection.rpcEndpoint, - programId?.toBase58() ?? 'none', - idlAddress?.toBase58() ?? 'derived', - signingWallet?.publicKey?.toBase58() ?? 'read-only', - ], + // IDL is a global program artifact — it never changes per wallet. + // Keyed only on (endpoint, programId) so we fetch it exactly once and + // re-use across wallet connections, eliminating the re-fetch delay each + // time a wallet is connected or switched. + const idlQuery = useQuery({ + queryKey: ['cryptopets', 'idl', connection.rpcEndpoint, programId?.toBase58() ?? 'none'], enabled: programId !== null, - queryFn: async (): Promise => { + staleTime: Infinity, + queryFn: async (): Promise => { if (!programId) { throw new Error('Solana program id is not configured'); } - // Use an explicit IDL account address when provided (VITE_CRYPTOPETS_IDL_ADDRESS), - // otherwise fall back to the PDA Anchor derives from the program id. - const fetchAddress = idlAddress ?? programId; - const idl = await Program.fetchIdl(fetchAddress, provider); + const idl = await Program.fetchIdl(programId, provider); if (!idl) { throw new Error( 'IDL not found on-chain for this program. Deploy the IDL (`anchor idl init`) or point RPC at a cluster where it exists.' ); } - return new Program(idl, provider) as SolanaProgram; + return idl; }, }); + // Program is rebuilt from the cached IDL whenever the provider changes + // (wallet connect/disconnect/switch). No extra network request. + const program = useMemo(() => { + if (!idlQuery.data) return null; + return new Program(idlQuery.data, provider) as SolanaProgram; + }, [idlQuery.data, provider]); + return { programId, - program: query.data ?? null, + program, provider: signingWallet ? provider : null, isConfigured: programId !== null, - isLoading: query.isPending, - isFetching: query.isFetching, - error: query.error, - refetch: query.refetch, - isReady: Boolean(programId && query.data), + isLoading: idlQuery.isPending, + isFetching: idlQuery.isFetching, + error: idlQuery.error, + refetch: idlQuery.refetch, + isReady: Boolean(programId && program), }; } diff --git a/shared/src/hooks/chains/solana/useSolanaFees.ts b/shared/src/hooks/chains/solana/useSolanaFees.ts index cd61859f..d612ff28 100644 --- a/shared/src/hooks/chains/solana/useSolanaFees.ts +++ b/shared/src/hooks/chains/solana/useSolanaFees.ts @@ -44,7 +44,7 @@ export const useSolanaFees = (enabled: boolean): SolanaFees => { enabled: canQuery, queryFn: async () => { const [pda] = globalStatePda(programId!); - return getAccountClient(program!, 'globalState').fetch(pda) as Promise>; + return getAccountClient(program!, 'globalState').fetchNullable(pda) as Promise | null>; }, staleTime: 60_000, }); diff --git a/shared/tests/hooks/useSolanaFees.test.tsx b/shared/tests/hooks/useSolanaFees.test.tsx index b16c609c..ab40ef02 100644 --- a/shared/tests/hooks/useSolanaFees.test.tsx +++ b/shared/tests/hooks/useSolanaFees.test.tsx @@ -46,8 +46,8 @@ const fetchPP = vi.fn().mockResolvedValue(playerProfileData); vi.mock('../../src/utils/solana/accountClient', () => ({ getAccountClient: (_prog: unknown, name: string) => ({ - fetch: name === 'globalState' ? fetchGS : vi.fn(), - fetchNullable: name === 'playerProfile' ? fetchPP : vi.fn().mockResolvedValue(null), + fetch: vi.fn(), + fetchNullable: name === 'globalState' ? fetchGS : name === 'playerProfile' ? fetchPP : vi.fn().mockResolvedValue(null), }), })); From 31bfc8fa8aba89fff30621a0d1df1f870bef74c9 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 10:06:49 -0400 Subject: [PATCH 16/48] fix: restore idlAddress fallback in useProgram IDL fetch --- shared/src/hooks/chains/solana/useProgram.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/shared/src/hooks/chains/solana/useProgram.ts b/shared/src/hooks/chains/solana/useProgram.ts index a33ec386..fdd81f2f 100644 --- a/shared/src/hooks/chains/solana/useProgram.ts +++ b/shared/src/hooks/chains/solana/useProgram.ts @@ -15,7 +15,7 @@ const READ_ONLY_WALLET: SolanaSigningWallet = { export type SolanaProgram = Program; export const useProgram = () => { - const { connection, programId, signingWallet } = useSolanaAnchor(); + const { connection, programId, idlAddress, signingWallet } = useSolanaAnchor(); const providerWallet = signingWallet ?? READ_ONLY_WALLET; @@ -33,14 +33,21 @@ export const useProgram = () => { // re-use across wallet connections, eliminating the re-fetch delay each // time a wallet is connected or switched. const idlQuery = useQuery({ - queryKey: ['cryptopets', 'idl', connection.rpcEndpoint, programId?.toBase58() ?? 'none'], + queryKey: [ + 'cryptopets', + 'idl', + connection.rpcEndpoint, + programId?.toBase58() ?? 'none', + idlAddress?.toBase58() ?? 'derived', + ], enabled: programId !== null, staleTime: Infinity, queryFn: async (): Promise => { if (!programId) { throw new Error('Solana program id is not configured'); } - const idl = await Program.fetchIdl(programId, provider); + const fetchAddress = idlAddress ?? programId; + const idl = await Program.fetchIdl(fetchAddress, provider); if (!idl) { throw new Error( 'IDL not found on-chain for this program. Deploy the IDL (`anchor idl init`) or point RPC at a cluster where it exists.' From 54fa5783ebd10de721327007f87a24ef338be5e7 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 10:53:45 -0400 Subject: [PATCH 17/48] fix: pass programId to fetchIdl so the on-chain IDL resolves --- shared/src/hooks/chains/solana/useProgram.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/shared/src/hooks/chains/solana/useProgram.ts b/shared/src/hooks/chains/solana/useProgram.ts index fdd81f2f..98a4000b 100644 --- a/shared/src/hooks/chains/solana/useProgram.ts +++ b/shared/src/hooks/chains/solana/useProgram.ts @@ -15,7 +15,7 @@ const READ_ONLY_WALLET: SolanaSigningWallet = { export type SolanaProgram = Program; export const useProgram = () => { - const { connection, programId, idlAddress, signingWallet } = useSolanaAnchor(); + const { connection, programId, signingWallet } = useSolanaAnchor(); const providerWallet = signingWallet ?? READ_ONLY_WALLET; @@ -33,21 +33,16 @@ export const useProgram = () => { // re-use across wallet connections, eliminating the re-fetch delay each // time a wallet is connected or switched. const idlQuery = useQuery({ - queryKey: [ - 'cryptopets', - 'idl', - connection.rpcEndpoint, - programId?.toBase58() ?? 'none', - idlAddress?.toBase58() ?? 'derived', - ], + queryKey: ['cryptopets', 'idl', connection.rpcEndpoint, programId?.toBase58() ?? 'none'], enabled: programId !== null, staleTime: Infinity, queryFn: async (): Promise => { if (!programId) { throw new Error('Solana program id is not configured'); } - const fetchAddress = idlAddress ?? programId; - const idl = await Program.fetchIdl(fetchAddress, provider); + // Pass the program id — Anchor's fetchIdl derives the on-chain IDL PDA itself. + // (Do NOT pass the derived IDL account address; it would derive a PDA-of-a-PDA.) + const idl = await Program.fetchIdl(programId, provider); if (!idl) { throw new Error( 'IDL not found on-chain for this program. Deploy the IDL (`anchor idl init`) or point RPC at a cluster where it exists.' From 9e439874a2bc41c309a1a8ee91cb95826c886d7a Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 11:18:08 -0400 Subject: [PATCH 18/48] chore: add initialize script for fresh program deployments --- contracts/solana/cryptopets/package.json | 3 +- .../solana/cryptopets/scripts/initialize.ts | 98 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 contracts/solana/cryptopets/scripts/initialize.ts diff --git a/contracts/solana/cryptopets/package.json b/contracts/solana/cryptopets/package.json index 9741b816..812e8afd 100644 --- a/contracts/solana/cryptopets/package.json +++ b/contracts/solana/cryptopets/package.json @@ -4,7 +4,8 @@ "scripts": { "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check", - "inject-ngrok": "tsx scripts/inject-ngrok.ts" + "inject-ngrok": "tsx scripts/inject-ngrok.ts", + "initialize": "tsx scripts/initialize.ts" }, "dependencies": { "@coral-xyz/anchor": "^0.32.1" diff --git a/contracts/solana/cryptopets/scripts/initialize.ts b/contracts/solana/cryptopets/scripts/initialize.ts new file mode 100644 index 00000000..d6cf9a6e --- /dev/null +++ b/contracts/solana/cryptopets/scripts/initialize.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env tsx +// +// One-time on-chain setup for a freshly deployed `cryptopets` program: runs the +// `initialize` instruction, which creates the `global-state` PDA (admin + fee +// config + next_pet_id) and the Metaplex Core collection that every pet is +// minted into. Without this, mint/breed/battle and pet loading have nothing to +// read or write, so the frontend shows an empty list and create fails. +// +// Idempotent: if `global-state` already exists it prints the current config and +// exits without sending a transaction. +// +// Usage (devnet): +// ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \ +// ANCHOR_WALLET=$HOME/.config/solana/id.json \ +// pnpm exec tsx scripts/initialize.ts +// +// The wallet must be the intended program admin and hold a little devnet SOL. + +import * as anchor from "@coral-xyz/anchor"; +import { globalStatePda } from "../tests/utils"; + +// Defaults to the program id pinned in Anchor.toml ([programs.devnet]); override +// with PROGRAM_ID=... if you deploy under a different key. +const PROGRAM_ID = new anchor.web3.PublicKey( + process.env.PROGRAM_ID ?? "88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k", +); + +// mpl-core program (stable across clusters). Required by the `initialize` CPI +// that creates the collection. +const MPL_CORE_PROGRAM_ID = new anchor.web3.PublicKey( + "CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d", +); + +// Initial level-up fee in lamports; matches the test default. Tune via +// LEVEL_UP_FEE_LAMPORTS=... or adjust later with the `set_level_up_fee_lamports` +// admin instruction. +const LEVEL_UP_FEE_LAMPORTS = new anchor.BN( + process.env.LEVEL_UP_FEE_LAMPORTS ?? 1_000_000, +); + +async function main() { + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + const wallet = provider.wallet as anchor.Wallet; + + const idl = await anchor.Program.fetchIdl(PROGRAM_ID, provider); + if (!idl) { + throw new Error( + `IDL not found on-chain for ${PROGRAM_ID.toBase58()}. Deploy the program and run \`anchor idl init\` first.`, + ); + } + const program = new anchor.Program(idl, provider); + + const [globalState] = globalStatePda(PROGRAM_ID); + + console.log("program :", PROGRAM_ID.toBase58()); + console.log("admin :", wallet.publicKey.toBase58()); + console.log("cluster :", provider.connection.rpcEndpoint); + console.log("globalState:", globalState.toBase58()); + + const existing = await provider.connection.getAccountInfo(globalState); + if (existing) { + const gs = await (program.account as any).globalState.fetch(globalState); + console.log("\n✅ Already initialized — nothing to do."); + console.log(" collection :", gs.collection.toBase58()); + console.log(" admin :", gs.admin.toBase58()); + console.log(" nextPetId :", gs.nextPetId); + console.log(" paused :", gs.paused); + return; + } + + const collection = anchor.web3.Keypair.generate(); + console.log("\nInitializing… new collection:", collection.publicKey.toBase58()); + + const sig = await program.methods + .initialize(LEVEL_UP_FEE_LAMPORTS) + .accounts({ + globalState, + collection: collection.publicKey, + admin: wallet.publicKey, + mplCoreProgram: MPL_CORE_PROGRAM_ID, + }) + .signers([collection]) + .rpc(); + + console.log("\n✅ Initialized."); + console.log(" tx :", sig); + console.log(" collection :", collection.publicKey.toBase58()); + console.log( + "\nSave the collection address — it lives in global-state and is used by", + "every mint/sync. No env change is needed; the frontend reads it on-chain.", + ); +} + +main().catch((err) => { + console.error("\n❌ initialize failed:", err instanceof Error ? err.message : err); + process.exit(1); +}); From e772e71236b3829af3faa54293f850c2b4d5b8d3 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 16:14:09 -0400 Subject: [PATCH 19/48] fix(solana): drop update_authority from mpl-core asset create CPI --- .../src/instructions/breeding/settle_breed.rs | 13 +++++-------- .../cryptopets/src/instructions/mint/settle_mint.rs | 13 +++++-------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs index c40b5679..5d16e9fb 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/breeding/settle_breed.rs @@ -123,13 +123,11 @@ pub fn handler(ctx: Context) -> Result<()> { // `invoke_signed` (it does not sign the outer transaction). See `settle_mint`'s CPI // for the same pattern. // - // UNVERIFIED: `CreateV1CpiBuilder`'s method names/shapes (`asset`/`collection`/ - // `authority`/`payer`/`owner`/`update_authority`/`system_program`/`name`/`uri`/ - // `plugins`/`invoke_signed`), plus `PluginAuthorityPair`/`Plugin::Attributes`/ - // `Attributes`'s field shapes, follow the usual mpl-core ~0.10 CPI convention but have - // not been checked against the real crate (no cargo registry cache or Rust toolchain - // in this environment). Fix up against `mpl_core::instructions::CreateV1CpiBuilder` - // and `mpl_core::types` when building. + // NOTE: do NOT set `update_authority` here. When an asset is created into a + // `collection`, mpl-core inherits the asset's update authority from the collection; + // passing both a collection and an explicit `update_authority` fails with + // `MplCoreError::ConflictingAuthority` (0x1d). `authority` (GlobalState) is the + // collection's update authority signing the add-to-collection — that is sufficient. let global_state_seeds: &[&[u8]] = &[GlobalState::SEED, &[global_state.bump]]; CreateV1CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info()) .asset(&ctx.accounts.asset.to_account_info()) @@ -137,7 +135,6 @@ pub fn handler(ctx: Context) -> Result<()> { .authority(Some(&global_state.to_account_info())) .payer(&ctx.accounts.owner.to_account_info()) .owner(Some(&ctx.accounts.owner.to_account_info())) - .update_authority(Some(&global_state.to_account_info())) .system_program(&ctx.accounts.system_program.to_account_info()) .name(child.name()) .uri(String::new()) diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs index a868e8fe..1833eccc 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/mint/settle_mint.rs @@ -76,13 +76,11 @@ pub fn handler(ctx: Context) -> Result<()> { // GlobalState PDA is the collection's update authority and signs this CPI via // `invoke_signed` (it does not sign the outer transaction). // - // UNVERIFIED: `CreateV1CpiBuilder`'s method names/shapes (`asset`/`collection`/ - // `authority`/`payer`/`owner`/`update_authority`/`system_program`/`name`/`uri`/ - // `plugins`/`invoke_signed`), plus `PluginAuthorityPair`/`Plugin::Attributes`/ - // `Attributes`'s field shapes, follow the usual mpl-core ~0.10 CPI convention but have - // not been checked against the real crate (no cargo registry cache or Rust toolchain - // in this environment). Fix up against `mpl_core::instructions::CreateV1CpiBuilder` - // and `mpl_core::types` when building. + // NOTE: do NOT set `update_authority` here. When an asset is created into a + // `collection`, mpl-core inherits the asset's update authority from the collection; + // passing both a collection and an explicit `update_authority` fails with + // `MplCoreError::ConflictingAuthority` (0x1d). `authority` (GlobalState) is the + // collection's update authority signing the add-to-collection — that is sufficient. let global_state_seeds: &[&[u8]] = &[GlobalState::SEED, &[global_state.bump]]; CreateV1CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info()) .asset(&ctx.accounts.asset.to_account_info()) @@ -90,7 +88,6 @@ pub fn handler(ctx: Context) -> Result<()> { .authority(Some(&global_state.to_account_info())) .payer(&ctx.accounts.owner.to_account_info()) .owner(Some(&ctx.accounts.owner.to_account_info())) - .update_authority(Some(&global_state.to_account_info())) .system_program(&ctx.accounts.system_program.to_account_info()) .name(pet.name()) .uri(String::new()) From 559c1ada1135c94da544586563d4dea0a1c5e308 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 16:36:27 -0400 Subject: [PATCH 20/48] feat(solana): add transfer_pet instruction and wire pet sending --- .../programs/cryptopets/src/errors.rs | 2 + .../cryptopets/src/instructions/pet/mod.rs | 2 + .../src/instructions/pet/transfer_pet.rs | 85 +++++++++++++++++++ .../cryptopets/programs/cryptopets/src/lib.rs | 4 + shared/src/hooks/adapters/useSolanaAdapter.ts | 11 +-- .../src/hooks/chains/solana/usePetActions.ts | 28 ++++++ shared/tests/hooks/useSolanaAdapter.test.tsx | 8 +- 7 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/transfer_pet.rs diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs b/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs index 3886667c..53c478b4 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/errors.rs @@ -118,5 +118,7 @@ pub enum ErrorCode { MintRequestNotFound, #[msg("Asset account does not match this pet's Metaplex Core asset")] InvalidPetAsset, + #[msg("Cannot transfer a married pet; divorce first")] + CannotTransferMarriedPet, } diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/mod.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/mod.rs index dae0e0f3..a0c6db75 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/mod.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/mod.rs @@ -6,8 +6,10 @@ pub mod level_up; pub mod rename_pet; pub mod sync_metadata; pub mod train; +pub mod transfer_pet; pub use level_up::*; pub use rename_pet::*; pub use sync_metadata::*; pub use train::*; +pub use transfer_pet::*; diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/transfer_pet.rs b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/transfer_pet.rs new file mode 100644 index 00000000..b4b5ec5a --- /dev/null +++ b/contracts/solana/cryptopets/programs/cryptopets/src/instructions/pet/transfer_pet.rs @@ -0,0 +1,85 @@ +use anchor_lang::prelude::*; +use mpl_core::instructions::TransferV1CpiBuilder; + +use crate::{ + errors::ErrorCode, + state::{GlobalState, PetAccount}, + utils::metadata::core_asset_owner, +}; + +/// Transfer a pet to another wallet. Pets are Metaplex Core assets and the asset is the +/// source of truth for ownership (plan §2.3/v2.1 Phase A), so this CPIs mpl-core +/// `TransferV1` to move the asset and then updates the denormalized `pet.owner` snapshot so +/// owner-filtered queries (the gallery's `getProgramAccounts` memcmp on `owner`) keep +/// finding the pet under its new owner. +/// +/// A married pet is refused: the spouse's `PetAccount` cross-references this pet (and its +/// `marriage_owner_snapshot` drives cross-owner breeding), so it must be divorced first. +pub fn handler(ctx: Context) -> Result<()> { + require!(!ctx.accounts.global_state.paused, ErrorCode::Paused); + + // Only the current (live) asset owner may transfer. mpl-core re-checks this in the CPI; + // we assert it up front for a clean error and to gate the `pet.owner` write below. + require_keys_eq!( + core_asset_owner(&ctx.accounts.pet_asset.to_account_info())?, + ctx.accounts.owner.key(), + ErrorCode::Unauthorized + ); + + require!( + ctx.accounts.pet.spouse_id == 0, + ErrorCode::CannotTransferMarriedPet + ); + + // mpl-core CPI: move the Core asset to `new_owner`. The asset's owner is the transfer + // authority and signs the outer transaction, so this is `invoke()` (no PDA seeds). The + // collection must be supplied for collection-scoped assets; it is not mutated here. + TransferV1CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info()) + .asset(&ctx.accounts.pet_asset.to_account_info()) + .collection(Some(&ctx.accounts.collection.to_account_info())) + .payer(&ctx.accounts.owner.to_account_info()) + .authority(Some(&ctx.accounts.owner.to_account_info())) + .new_owner(&ctx.accounts.new_owner.to_account_info()) + .system_program(Some(&ctx.accounts.system_program.to_account_info())) + .invoke()?; + + // Keep the cached owner in sync with the asset's new owner. + ctx.accounts.pet.owner = ctx.accounts.new_owner.key(); + + Ok(()) +} + +#[derive(Accounts)] +pub struct TransferPet<'info> { + #[account(seeds = [GlobalState::SEED], bump = global_state.bump)] + pub global_state: Account<'info, GlobalState>, + + /// CHECK: this pet's Metaplex Core asset account; PDA seed for `pet` and source of + /// truth for ownership (plan §2.3/v2.1 Phase A). Mutated by the `TransferV1` CPI. + #[account(mut, owner = mpl_core::ID)] + pub pet_asset: UncheckedAccount<'info>, + + #[account( + mut, + seeds = [PetAccount::SEED, pet_asset.key().as_ref()], + bump = pet.bump, + )] + pub pet: Account<'info, PetAccount>, + + /// CHECK: the "CryptoPets" collection account (`global_state.collection`); supplied to + /// the `TransferV1` CPI to validate collection membership. Not mutated. + #[account(address = global_state.collection)] + pub collection: UncheckedAccount<'info>, + + /// CHECK: recipient wallet that receives the Core asset. Any pubkey is valid. + pub new_owner: UncheckedAccount<'info>, + + #[account(mut)] + pub owner: Signer<'info>, + + /// CHECK: address-constrained to the Metaplex Core program; invoked via CPI. + #[account(address = mpl_core::ID)] + pub mpl_core_program: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} diff --git a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs index d2dc7719..92a1e8d6 100644 --- a/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs +++ b/contracts/solana/cryptopets/programs/cryptopets/src/lib.rs @@ -29,6 +29,10 @@ pub mod cryptopets { rename_pet::handler(ctx, name) } + pub fn transfer_pet(ctx: Context) -> Result<()> { + transfer_pet::handler(ctx) + } + pub fn set_open_to_challenges(ctx: Context, value: bool) -> Result<()> { set_open_to_challenges::handler(ctx, value) } diff --git a/shared/src/hooks/adapters/useSolanaAdapter.ts b/shared/src/hooks/adapters/useSolanaAdapter.ts index af3a1ace..264926bf 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -117,13 +117,14 @@ export const useSolanaAdapter = ({ enabled }: { enabled: boolean }): ChainAdapte isPending: actions.renamePet.isPending, }; - // Transfers happen via Metaplex Core (NFT transfer) — no program-level instruction in v2.1. + // `transfer_pet` CPIs mpl-core TransferV1 to move the Core asset and syncs the + // denormalized `PetAccount.owner` so the gallery's owner-memcmp query follows the pet. const transferPet: AdapterMutation<{ petId: string; to: string }> = { - async mutateAsync() { - throw new Error('Solana pet transfers use Metaplex Core — use the NFT wallet interface'); + async mutateAsync({ petId, to }) { + await actions.transferPet.mutateAsync({ assetKey: requireAssetKey(petId), to }); }, - lifecycle: { phase: 'idle', error: null, reset: () => undefined }, - isPending: false, + lifecycle: toLc(actions.transferPet), + isPending: actions.transferPet.isPending, }; const battleLc = useMemo(() => { diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index e849ccad..6825b5a9 100644 --- a/shared/src/hooks/chains/solana/usePetActions.ts +++ b/shared/src/hooks/chains/solana/usePetActions.ts @@ -114,6 +114,33 @@ export const usePetActions = () => { onSuccess: invalidateProgramQueries, }); + const transferPet = useMutation({ + mutationFn: async (args: { assetKey: string; to: string }) => { + const { program, programId, owner } = requireReady(); + const petAsset = new PublicKey(args.assetKey); + const [pet] = petPdaByAsset(programId, args.assetKey); + const [globalState] = globalStatePda(programId); + const gs = (await getAccountClient(program, 'globalState').fetch(globalState)) as { collection: unknown }; + const collection = gs.collection instanceof PublicKey + ? gs.collection + : new PublicKey(String((gs.collection as { toBase58(): string }).toBase58?.() ?? gs.collection)); + return program.methods + .transferPet() + .accounts({ + globalState, + petAsset, + pet, + collection, + newOwner: new PublicKey(args.to), + owner, + mplCoreProgram: new PublicKey(MPL_CORE_PROGRAM_ID), + systemProgram: SystemProgram.programId, + }) + .rpc(); + }, + onSuccess: invalidateProgramQueries, + }); + const withdrawStudFees = useMutation({ mutationFn: async () => { const { program, programId, owner } = requireReady(); @@ -245,6 +272,7 @@ export const usePetActions = () => { levelUpPet, trainPet, renamePet, + transferPet, withdrawStudFees, syncMetadata, setOpenToChallenges, diff --git a/shared/tests/hooks/useSolanaAdapter.test.tsx b/shared/tests/hooks/useSolanaAdapter.test.tsx index 18ba773b..669e7169 100644 --- a/shared/tests/hooks/useSolanaAdapter.test.tsx +++ b/shared/tests/hooks/useSolanaAdapter.test.tsx @@ -30,6 +30,7 @@ const actions = { renamePet: makeMutation(), battlePets: makeMutation({ sig: 'settle-sig', firstWins: true }), breedPets: makeMutation(), + transferPet: makeMutation(), setOpenToChallenges: makeMutation(), syncMetadata: makeMutation(), withdrawStudFees: makeMutation(), @@ -69,6 +70,7 @@ beforeEach(() => { Object.assign(actions.renamePet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.battlePets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.breedPets, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); + Object.assign(actions.transferPet, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.setOpenToChallenges, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.syncMetadata, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); Object.assign(actions.withdrawStudFees, { isPending: false, isSuccess: false, isError: false, error: null, data: undefined }); @@ -198,9 +200,9 @@ describe('useSolanaAdapter', () => { expect(hook.result.current.createPet.lifecycle.phase).toBe('error'); }); - it('transferPet throws with an explanatory message', async () => { + it('transferPet forwards the pet asset key and recipient', async () => { const { result } = renderHook(() => useSolanaAdapter({ enabled: true })); - await expect(result.current.transferPet.mutateAsync({ petId: '1', to: validAddress })) - .rejects.toThrow(/Metaplex Core/); + await result.current.transferPet.mutateAsync({ petId: '1', to: validAddress }); + expect(actions.transferPet.mutateAsync).toHaveBeenCalledWith({ assetKey: ASSET_1, to: validAddress }); }); }); From 22378c0f9f9114c65904c4b945a282df13dc4ae8 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 17:01:39 -0400 Subject: [PATCH 21/48] feat: drop backend sign-in requirement for first pet --- .../components/pet/creation/create-pet-modal/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.tsx b/frontend/src/components/pet/creation/create-pet-modal/index.tsx index 1bf5788e..c8fedf8f 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.tsx +++ b/frontend/src/components/pet/creation/create-pet-modal/index.tsx @@ -8,7 +8,6 @@ import { import { Tones } from '@constants/tones'; import Icon, { CheckIcon, PawIcon } from '@components/ui/icon'; import TransactionStatus from '@components/common/transaction-status'; -import { AuthActionButton } from '@components/common'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; import './index.css'; @@ -126,13 +125,16 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => {

Mint cost: {mintCost}

)} - {buttonLabel} - + {isAwaitingFulfillment && (

From 1f7fc08b04fbcf5523fbe60d6236559815f804b3 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 17:36:12 -0400 Subject: [PATCH 22/48] feat: allow custom devnet RPC via VITE_SOLANA_DEVNET_RPC_URL --- frontend/src/constants/chains/solana.ts | 5 ++++- frontend/src/vite-env.d.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/constants/chains/solana.ts b/frontend/src/constants/chains/solana.ts index dcf3bf87..2f916f53 100644 --- a/frontend/src/constants/chains/solana.ts +++ b/frontend/src/constants/chains/solana.ts @@ -7,10 +7,13 @@ export interface SolanaNetworkConfig { } const localRpcUrl = import.meta.env.VITE_SOLANA_LOCAL_RPC_URL || 'http://localhost:8899'; +// Prefer a dedicated devnet RPC (Helius/QuickNode/etc.) when provided; the public +// clusterApiUrl('devnet') endpoint is heavily rate-limited and makes reads/sends flaky. +const devnetRpcUrl = import.meta.env.VITE_SOLANA_DEVNET_RPC_URL || clusterApiUrl('devnet'); export const SOLANA_NETWORKS: SolanaNetworkConfig[] = [ { name: 'Solana Local', rpcUrl: localRpcUrl, isTestnet: true }, - { name: 'Solana Devnet', rpcUrl: clusterApiUrl('devnet'), isTestnet: true }, + { name: 'Solana Devnet', rpcUrl: devnetRpcUrl, isTestnet: true }, { name: 'Solana Testnet', rpcUrl: clusterApiUrl('testnet'), isTestnet: true }, { name: 'Solana Mainnet', rpcUrl: clusterApiUrl('mainnet-beta'), isTestnet: false }, ]; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index bb9bbfbf..eaca6a9e 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_DYNAMIC_ENVIRONMENT_ID: string; readonly VITE_SOLANA_LOCAL_RPC_URL?: string; + /** Override the devnet RPC endpoint (e.g. a dedicated Helius/QuickNode URL). Falls back to the public clusterApiUrl('devnet') when unset. */ + readonly VITE_SOLANA_DEVNET_RPC_URL?: string; readonly VITE_API_URL?: string; /** v2 PetCore UUPS proxy address (ERC-721 storage, mint, level/XP, marriage). */ readonly VITE_PETCORE_ADDRESS?: string; From f3323331aaa8418ecc75d7b00fda17f89a4b7f01 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 19:05:26 -0400 Subject: [PATCH 23/48] fix: make interactions panel body scroll in all layouts --- frontend/src/components/layout/index.css | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/components/layout/index.css b/frontend/src/components/layout/index.css index 02a27f69..996ab83a 100644 --- a/frontend/src/components/layout/index.css +++ b/frontend/src/components/layout/index.css @@ -160,6 +160,19 @@ } } +/* The interactions panel hosts tall content (battle setup, breed, etc.) whose + action buttons sit at the bottom. The generic .panel-body is `overflow: hidden`, + which clips them once the content exceeds the panel height. Let the body scroll + here (the title bar stays pinned) so the Start Battle / action buttons are always + reachable. Applies in every layout that shows this panel (standalone or beside + the gallery); the stacked mobile dashboard re-overrides this to `visible` below. */ +.dashboard-panel.pet-interactions .panel-body { + overflow-x: clip; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--neon-cyan) transparent; +} + /* Dashboard view (interactions hub + pet gallery): split horizontally 50/50 so both panels share equal width and the pet cards render in full. */ .main-content:has(.dashboard-panel.pet-collection) { @@ -243,6 +256,12 @@ overflow: visible; } + /* Stacked layout scrolls the whole page (main-content), so the panel body + should grow with its content rather than scroll on its own. */ + & .panel-body { + overflow: visible; + } + & .action-buttons { display: grid; flex: 0 0 auto; From acfcf5258cba3d99bab3be8515273e17f6284633 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 19:13:34 -0400 Subject: [PATCH 24/48] feat: two-lane matchmaking layout for fighter/opponent pickers --- .../pet/interactions/panels/battle/index.css | 82 ++++++++++++++----- .../panels/battle/parts/battle-setup.tsx | 2 + 2 files changed, 64 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/pet/interactions/panels/battle/index.css b/frontend/src/components/pet/interactions/panels/battle/index.css index 28b9d50a..a91f8376 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.css +++ b/frontend/src/components/pet/interactions/panels/battle/index.css @@ -257,41 +257,83 @@ } } -.battle-picker-strip { +/* Two-lane matchmaking layout: your fighters on the left, opponents on the right, + each lane scrolling vertically and independently. The VS arena stays above and + the Start Battle bar below, so neither list pushes the action off-screen. */ +.battle-rosters { display: flex; - gap: 10px; + gap: 14px; width: 100%; - max-width: 100%; - box-sizing: border-box; - overflow-x: auto; - overflow-y: hidden; - padding: 6px; - scroll-padding: 6px; - scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; + min-width: 0; + align-items: stretch; +} - .battle-picker-card { - flex: 0 0 min(168px, calc(100% - 12px)); - max-width: calc(100% - 12px); - } +.battle-rosters .battle-picker-section { + flex: 1 1 0; + min-width: 0; + background: rgb(8 14 30 / 42%); + border: 1px solid var(--zi-border); + border-radius: 12px; + padding: 12px; + box-shadow: inset 0 0 24px rgb(0 0 0 / 22%); +} + +/* Lane identity: fighters read cyan (you), opponents read magenta (them). */ +.battle-rosters .battle-picker-section[aria-label='Your fighters'] { + border-color: rgb(125 214 255 / 32%); + box-shadow: inset 0 0 24px rgb(125 214 255 / 6%); +} + +.battle-rosters .battle-picker-section[aria-label='Opponents'] { + border-color: rgb(255 110 196 / 32%); + box-shadow: inset 0 0 24px rgb(255 110 196 / 6%); +} + +.battle-rosters .battle-picker-section[aria-label='Opponents'] .section-title { + color: #ff9ad6; +} + +/* The header stays pinned while the card list below it scrolls. */ +.battle-rosters .section-head { + flex: 0 0 auto; } +.battle-picker-strip, .battle-opponent-grid { display: flex; + flex-direction: column; gap: 10px; width: 100%; max-width: 100%; box-sizing: border-box; - overflow-x: auto; - overflow-y: hidden; - padding: 6px; - scroll-padding: 6px; + overflow-x: hidden; + overflow-y: auto; + max-height: clamp(240px, 44vh, 560px); + padding: 4px; + scroll-padding: 4px; scrollbar-width: thin; scrollbar-color: var(--neon-cyan) transparent; .battle-picker-card { - flex: 0 0 min(168px, calc(100% - 12px)); - max-width: calc(100% - 12px); + flex: 0 0 auto; + width: 100%; + max-width: 100%; + } +} + +.battle-opponent-grid { + scrollbar-color: #ff9ad6 transparent; +} + +/* Stack the two lanes on narrow screens so each stays usable. */ +@media (max-width: 720px) { + .battle-rosters { + flex-direction: column; + } + + .battle-picker-strip, + .battle-opponent-grid { + max-height: clamp(180px, 30vh, 360px); } } diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index a9274940..a1799840 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -121,6 +121,7 @@ const BattleSetup: React.FC = ({ {opponent ? : null} +

Your fighters
@@ -185,6 +186,7 @@ const BattleSetup: React.FC = ({
)}
+
{isArenaReady && (
From 7fce4b2389167b8d2330da0cc5d9812d60b4e1f2 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 19:20:21 -0400 Subject: [PATCH 25/48] feat: flank the VS arena with fighter/opponent lanes --- .../pet/interactions/panels/battle/index.css | 41 ++- .../panels/battle/parts/battle-setup.tsx | 258 +++++++++--------- 2 files changed, 158 insertions(+), 141 deletions(-) diff --git a/frontend/src/components/pet/interactions/panels/battle/index.css b/frontend/src/components/pet/interactions/panels/battle/index.css index a91f8376..fd814160 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.css +++ b/frontend/src/components/pet/interactions/panels/battle/index.css @@ -257,10 +257,10 @@ } } -/* Two-lane matchmaking layout: your fighters on the left, opponents on the right, - each lane scrolling vertically and independently. The VS arena stays above and - the Start Battle bar below, so neither list pushes the action off-screen. */ -.battle-rosters { +/* Arena stage: fighters lane on the outer-left, the VS arena card (with win odds + + Start Battle) in the center, opponents lane on the outer-right. Each lane + scrolls vertically and independently so neither pushes the action off-screen. */ +.battle-stage { display: flex; gap: 14px; width: 100%; @@ -268,7 +268,17 @@ align-items: stretch; } -.battle-rosters .battle-picker-section { +/* Center column holds the arena card, win odds, and the action bar. It gets the + most room so the VS slots stay legible. */ +.battle-center { + flex: 1.6 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.battle-stage .battle-picker-section { flex: 1 1 0; min-width: 0; background: rgb(8 14 30 / 42%); @@ -279,22 +289,22 @@ } /* Lane identity: fighters read cyan (you), opponents read magenta (them). */ -.battle-rosters .battle-picker-section[aria-label='Your fighters'] { +.battle-stage .battle-picker-section[aria-label='Your fighters'] { border-color: rgb(125 214 255 / 32%); box-shadow: inset 0 0 24px rgb(125 214 255 / 6%); } -.battle-rosters .battle-picker-section[aria-label='Opponents'] { +.battle-stage .battle-picker-section[aria-label='Opponents'] { border-color: rgb(255 110 196 / 32%); box-shadow: inset 0 0 24px rgb(255 110 196 / 6%); } -.battle-rosters .battle-picker-section[aria-label='Opponents'] .section-title { +.battle-stage .battle-picker-section[aria-label='Opponents'] .section-title { color: #ff9ad6; } /* The header stays pinned while the card list below it scrolls. */ -.battle-rosters .section-head { +.battle-stage .section-head { flex: 0 0 auto; } @@ -308,7 +318,7 @@ box-sizing: border-box; overflow-x: hidden; overflow-y: auto; - max-height: clamp(240px, 44vh, 560px); + max-height: clamp(260px, 52vh, 620px); padding: 4px; scroll-padding: 4px; scrollbar-width: thin; @@ -325,12 +335,17 @@ scrollbar-color: #ff9ad6 transparent; } -/* Stack the two lanes on narrow screens so each stays usable. */ -@media (max-width: 720px) { - .battle-rosters { +/* Below 960px the three columns get cramped: stack them, with the arena + + Start Battle on top, then the two lanes. */ +@media (max-width: 960px) { + .battle-stage { flex-direction: column; } + .battle-center { + order: -1; + } + .battle-picker-strip, .battle-opponent-grid { max-height: clamp(180px, 30vh, 360px); diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index a1799840..0d243733 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -76,145 +76,147 @@ const BattleSetup: React.FC = ({ )} -
-
- Battle Arena -
- - - {isArenaFighting ? 'Fighting' : showResult ? 'Complete' : isArenaReady ? 'Ready' : 'Setup'} - +
+
+
+
Your fighters
-
-
-
- -
-
- + {readyPets.length === 0 ? ( +
+ No ready pets. Wait for cooldowns to finish before battling.
-
VS
-
- -
-
- - - {opponent ? : null} - + ) : ( +
+ {readyPets.map(({ id, pet }) => ( + + ))} +
+ )} +
-
-
-
-
Your fighters
-
- {readyPets.length === 0 ? ( -
- No ready pets. Wait for cooldowns to finish before battling. -
- ) : ( -
- {readyPets.map(({ id, pet }) => ( - +
+
+ Battle Arena +
+ + + {isArenaFighting ? 'Fighting' : showResult ? 'Complete' : isArenaReady ? 'Ready' : 'Setup'} + +
+
+
+
+ +
+
+ +
+
VS
+
+ - ))} +
- )} -
-
-
-
- Opponents - {fighterLevel != null ? ( - · sorted by level match - ) : null} -
- -
- {opponentsLoading && sortedOpponents.length === 0 ? ( -
Finding challengers in the arena…
- ) : sortedOpponents.length === 0 ? ( -
- No opponents available right now. Check back after more players join the roster. -
- ) : ( -
- {sortedOpponents.map((o) => { - const key = opponentKey(o.owner, o.id); - return ( - - ); - })} -
- )} -
-
+ + {opponent ? : null} + - {isArenaReady && ( -
- {winEstimate.isLoading ? ( - Calculating odds… - ) : winEstimate.winProbability != null ? ( - <> - Win odds - = 0.5 ? ' favorable' : ' unfavorable'}`}> - {Math.round(winEstimate.winProbability * 100)}% - - {winEstimate.samples != null && ( - ({winEstimate.samples.toLocaleString()} sim) + {isArenaReady && ( +
+ {winEstimate.isLoading ? ( + Calculating odds… + ) : winEstimate.winProbability != null ? ( + <> + Win odds + = 0.5 ? ' favorable' : ' unfavorable'}`}> + {Math.round(winEstimate.winProbability * 100)}% + + {winEstimate.samples != null && ( + ({winEstimate.samples.toLocaleString()} sim) + )} + + ) : ( + Odds unavailable )} - - ) : ( - Odds unavailable +
)} + +
+ + {battleButtonLabel} + + +
- )} -
- - {battleButtonLabel} - - +
+
+
+ Opponents + {fighterLevel != null ? ( + · sorted by level match + ) : null} +
+ +
+ {opponentsLoading && sortedOpponents.length === 0 ? ( +
Finding challengers in the arena…
+ ) : sortedOpponents.length === 0 ? ( +
+ No opponents available right now. Check back after more players join the roster. +
+ ) : ( +
+ {sortedOpponents.map((o) => { + const key = opponentKey(o.owner, o.id); + return ( + + ); + })} +
+ )} +
); From af4e50be1222f633d3702c446f24ac6724f02842 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 19:28:19 -0400 Subject: [PATCH 26/48] fix: widen the standalone battle stage to use the full row --- frontend/src/components/pet/interactions/overview/index.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/pet/interactions/overview/index.css b/frontend/src/components/pet/interactions/overview/index.css index 2b5a4c55..00bb053d 100644 --- a/frontend/src/components/pet/interactions/overview/index.css +++ b/frontend/src/components/pet/interactions/overview/index.css @@ -17,8 +17,11 @@ margin-inline: auto; } + /* The battle setup is a 3-column stage (fighters · arena · opponents); give it + the surrounding space instead of the narrow single-panel cap so the lanes + spread to the edges and the arena stays centered. */ &.interaction-standalone:has(.battle-setup) { - max-width: 760px; + max-width: min(1440px, 100%); } .description { From dd96f768f5d4bfcb7c90690fd6aec53707ed1432 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 20:42:47 -0400 Subject: [PATCH 27/48] fix: stretch the battle stage to fill the panel height --- .../pet/interactions/panels/battle/index.css | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/pet/interactions/panels/battle/index.css b/frontend/src/components/pet/interactions/panels/battle/index.css index fd814160..7eabfd18 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.css +++ b/frontend/src/components/pet/interactions/panels/battle/index.css @@ -8,6 +8,9 @@ min-width: 0; max-width: 100%; overflow-x: clip; + /* Fill the panel height so the stage stretches instead of hugging the top. */ + flex: 1 1 auto; + min-height: 0; } .battle-setup-arena { @@ -266,6 +269,9 @@ width: 100%; min-width: 0; align-items: stretch; + /* Stretch to fill the battle-setup height; lanes scroll inside their column. */ + flex: 1 1 auto; + min-height: 0; } /* Center column holds the arena card, win odds, and the action bar. It gets the @@ -278,6 +284,12 @@ gap: 16px; } +/* Pin the action bar to the bottom of the center column so the arena + odds sit + up top and the filled height reads as intentional. */ +.battle-center .action-controls { + margin-top: auto; +} + .battle-stage .battle-picker-section { flex: 1 1 0; min-width: 0; @@ -318,7 +330,9 @@ box-sizing: border-box; overflow-x: hidden; overflow-y: auto; - max-height: clamp(260px, 52vh, 620px); + /* Fill the lane's remaining height (below its header) and scroll within. */ + flex: 1 1 auto; + min-height: 0; padding: 4px; scroll-padding: 4px; scrollbar-width: thin; @@ -338,6 +352,13 @@ /* Below 960px the three columns get cramped: stack them, with the arena + Start Battle on top, then the two lanes. */ @media (max-width: 960px) { + /* Stacked: let the content take its natural height and the panel scroll, + rather than flex-filling a single viewport of stacked lanes. */ + .dashboard-panel.pet-interactions .interface.battle-setup, + .battle-stage { + flex: 0 0 auto; + } + .battle-stage { flex-direction: column; } @@ -348,6 +369,7 @@ .battle-picker-strip, .battle-opponent-grid { + flex: 0 0 auto; max-height: clamp(180px, 30vh, 360px); } } From 693a31f80c162b3034adb1c0a9ad5d319f96a4ad Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sat, 20 Jun 2026 22:54:11 -0400 Subject: [PATCH 28/48] ui: redesign arena card with card shell, grid layout, and directional slot theming --- .../pet/interactions/panels/battle/index.css | 194 +++++++++++++----- 1 file changed, 145 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/pet/interactions/panels/battle/index.css b/frontend/src/components/pet/interactions/panels/battle/index.css index 7eabfd18..36b58ba7 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.css +++ b/frontend/src/components/pet/interactions/panels/battle/index.css @@ -18,12 +18,34 @@ position: relative; overflow: hidden; + /* Card shell — directional fighter/opponent tint with deep dark base */ + background: + linear-gradient(90deg, rgb(125 214 255 / 5%) 0%, transparent 38%, transparent 62%, rgb(255 110 196 / 5%) 100%), + radial-gradient(ellipse 100% 120% at 50% 110%, rgb(255 110 196 / 9%), transparent 55%), + linear-gradient(170deg, rgb(11 18 40 / 97%), rgb(5 9 22 / 99%)); + border: 1px solid rgb(255 110 196 / 26%); + border-radius: 16px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 4%), + inset 0 0 48px rgb(255 110 196 / 4%), + 0 0 24px rgb(255 110 196 / 8%), + 0 8px 32px rgb(0 0 0 / 30%); + + /* Suppress the hub-divider — gap handles spacing */ + .hub-divider { + display: none; + } + .header { display: flex; justify-content: space-between; align-items: center; gap: 8px; - font-size: 14px; + font-size: 13px; font-weight: 700; color: var(--zi-text); } @@ -38,55 +60,87 @@ .arena-badge { background: rgb(255 110 196 / 10%); color: #ff9ad6; - border: 1px solid rgb(255 110 196 / 44%); + border: 1px solid rgb(255 110 196 / 40%); border-radius: 999px; - padding: 2px 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.4px; + padding: 3px 10px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.6px; text-transform: uppercase; - box-shadow: 0 0 8px rgb(255 110 196 / 22%); + box-shadow: 0 0 10px rgb(255 110 196 / 18%), inset 0 0 8px rgb(255 110 196 / 6%); white-space: nowrap; } .arena-slot { - background: var(--zi-card-bg); - border: 2px solid var(--zi-border); - border-radius: 8px; - padding: 8px; + background: rgb(4 8 20 / 82%); + border: 1px solid var(--zi-border); + border-radius: 12px; + padding: 10px; display: grid; - gap: 6px; + gap: 8px; min-width: 0; max-width: 100%; width: 100%; - min-height: 72px; + min-height: 88px; box-sizing: border-box; overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease; &.is-empty { border-style: dashed; - border-color: rgb(125 214 255 / 28%); align-content: center; justify-items: center; text-align: center; + background: rgb(4 8 18 / 55%); .slot-placeholder { animation: battle-slot-pulse 2.4s ease-in-out infinite; } } - &.is-selected { - border-color: rgb(255 110 196 / 65%); - box-shadow: inset 0 0 14px rgb(255 110 196 / 18%); - animation: battle-slot-enter 0.42s cubic-bezier(0.22, 1, 0.36, 1); - } - &.is-flash { animation: battle-slot-flash 0.52s ease; } + + /* Fighter (left) = cyan identity */ + &.arena-slot-fighter { + border-color: rgb(125 214 255 / 20%); + + &.is-empty { + border-color: rgb(125 214 255 / 16%); + } + + &.is-selected { + border-color: rgb(125 214 255 / 52%); + box-shadow: inset 0 0 18px rgb(125 214 255 / 10%), 0 0 10px rgb(125 214 255 / 8%); + animation: battle-slot-enter 0.42s cubic-bezier(0.22, 1, 0.36, 1); + } + } + + /* Opponent (right) = magenta identity */ + &.arena-slot-opponent { + border-color: rgb(255 110 196 / 20%); + + &.is-empty { + border-color: rgb(255 110 196 / 16%); + } + + &.is-selected { + border-color: rgb(255 110 196 / 58%); + box-shadow: inset 0 0 18px rgb(255 110 196 / 12%), 0 0 10px rgb(255 110 196 / 8%); + animation: battle-slot-enter 0.42s cubic-bezier(0.22, 1, 0.36, 1); + } + } } &.is-ready { + border-color: rgb(255 110 196 / 42%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 4%), + inset 0 0 48px rgb(255 110 196 / 6%), + 0 0 32px rgb(255 110 196 / 14%), + 0 8px 32px rgb(0 0 0 / 30%); + .center { .icon { animation: battle-vs-glow 1.8s ease-in-out infinite; @@ -95,7 +149,7 @@ .vs { animation: battle-vs-pulse 1.8s ease-in-out infinite; color: #ff9ad6; - text-shadow: 0 0 10px rgb(255 110 196 / 45%); + text-shadow: 0 0 16px rgb(255 110 196 / 55%); } } } @@ -109,20 +163,52 @@ } &.is-result { - border-color: rgb(0 255 157 / 38%); - box-shadow: inset 0 0 18px rgb(0 255 157 / 10%); + border-color: rgb(0 255 157 / 40%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 4%), + inset 0 0 48px rgb(0 255 157 / 8%), + 0 0 30px rgb(0 255 157 / 16%), + 0 8px 32px rgb(0 0 0 / 30%); } .content { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: 10px; + align-items: center; min-width: 0; overflow: hidden; } .center { flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 0 4px; .icon { - box-shadow: inset 0 0 10px rgb(255 110 196 / 24%); + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: radial-gradient(circle at center, rgb(255 110 196 / 20%), rgb(255 110 196 / 6%) 65%, transparent); + border: 1px solid rgb(255 110 196 / 34%); + box-shadow: + inset 0 0 14px rgb(255 110 196 / 22%), + 0 0 20px rgb(255 110 196 / 12%); + } + + .vs { + font-size: 15px; + font-weight: 900; + letter-spacing: 3px; + color: rgb(255 110 196 / 58%); + text-shadow: 0 0 10px rgb(255 110 196 / 28%); + line-height: 1; } } @@ -143,16 +229,16 @@ .slot-avatar { flex-shrink: 0; - width: 32px; - height: 32px; + width: 38px; + height: 38px; display: flex; align-items: center; justify-content: center; - font-size: 1.35rem; + font-size: 1.5rem; line-height: 1; - border-radius: 8px; - background: rgb(5 13 30 / 78%); - border: 1px solid rgb(125 214 255 / 18%); + border-radius: 10px; + background: rgb(5 13 30 / 82%); + border: 1px solid rgb(125 214 255 / 16%); } .slot-meta { @@ -162,7 +248,7 @@ } .slot-name { - font-size: 12px; + font-size: 13px; font-weight: 700; color: var(--zi-text); overflow: hidden; @@ -179,17 +265,17 @@ } .life-track { - height: 7px; - background: rgb(5 13 30 / 78%); - border: 1px solid rgb(0 255 157 / 18%); + height: 5px; + background: rgb(4 8 18 / 90%); border-radius: 999px; overflow: hidden; } .life-fill { height: 100%; - background: linear-gradient(90deg, #0fffae, #9effd4); - box-shadow: inset 0 0 6px rgb(0 255 157 / 35%); + background: linear-gradient(90deg, #0dffb4, #7dffcc); + box-shadow: 0 0 8px rgb(0 255 157 / 55%); + border-radius: 999px; } } @@ -282,12 +368,9 @@ display: flex; flex-direction: column; gap: 16px; -} - -/* Pin the action bar to the bottom of the center column so the arena + odds sit - up top and the filled height reads as intentional. */ -.battle-center .action-controls { - margin-top: auto; + /* Center the arena (and odds + action bar) vertically within the full-height + column, so the VS card sits in the middle rather than hugging the top. */ + justify-content: center; } .battle-stage .battle-picker-section { @@ -782,11 +865,11 @@ @keyframes battle-vs-glow { 0%, 100% { - box-shadow: inset 0 0 10px rgb(255 110 196 / 24%); + box-shadow: inset 0 0 14px rgb(255 110 196 / 22%), 0 0 20px rgb(255 110 196 / 12%); } 50% { - box-shadow: inset 0 0 16px rgb(255 110 196 / 38%); + box-shadow: inset 0 0 20px rgb(255 110 196 / 38%), 0 0 28px rgb(255 110 196 / 22%); } } @@ -799,11 +882,19 @@ @keyframes battle-arena-clash { 0%, 100% { - box-shadow: inset 0 0 0 rgb(255 110 196 / 0%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 4%), + inset 0 0 48px rgb(255 110 196 / 4%), + 0 0 24px rgb(255 110 196 / 8%), + 0 8px 32px rgb(0 0 0 / 30%); } 50% { - box-shadow: inset 0 0 28px rgb(255 110 196 / 16%); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 4%), + inset 0 0 60px rgb(255 110 196 / 20%), + 0 0 40px rgb(255 110 196 / 22%), + 0 8px 32px rgb(0 0 0 / 30%); } } @@ -858,13 +949,18 @@ @media (max-width: 768px) { .battle-setup-arena { .content { - grid-template-columns: 1fr !important; + grid-template-columns: 1fr; } .center { - flex-direction: row !important; + flex-direction: row; justify-content: center; - gap: 8px; + gap: 10px; + + .icon { + width: 36px; + height: 36px; + } } .header { From 8fc38e724b5017afbcd9a07864fb401b5d5a065f Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sun, 21 Jun 2026 15:24:53 -0400 Subject: [PATCH 29/48] fix: auto-select married Solana pet and show spouse indicator in breed panel --- .../solana/cryptopets/scripts/set-config.ts | 111 ++++++++++++++++++ .../pet/interactions/panels/breed/index.tsx | 17 ++- 2 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 contracts/solana/cryptopets/scripts/set-config.ts diff --git a/contracts/solana/cryptopets/scripts/set-config.ts b/contracts/solana/cryptopets/scripts/set-config.ts new file mode 100644 index 00000000..a603401d --- /dev/null +++ b/contracts/solana/cryptopets/scripts/set-config.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env tsx +// +// Admin helper: calls any single-value config setter on the deployed `cryptopets` program. +// +// Usage (devnet): +// ANCHOR_PROVIDER_URL=https://api.devnet.solana.com \ +// ANCHOR_WALLET=$HOME/.config/solana/id.json \ +// pnpm exec tsx scripts/set-config.ts proposalTtlSeconds 86400 +// +// Available keys (all durations in seconds, fees in lamports): +// proposalTtlSeconds — how long a marriage proposal stays open (default: 60, max: 604800) +// marriageCooldownSeconds — cooldown after divorce before re-proposing (default: 60) +// battleCooldownSeconds — cooldown between battles (default: 5) +// trainCooldownSeconds — cooldown between trains (default: 60) +// trainXp — XP granted per train (default: 100) +// levelBandWidth — max level gap between battle participants (default: 100) +// maxLevel — hard level cap (default: 100) +// generationCap — max breeding generation (default: 20) +// newbornCooldownSeconds — post-breed battle lockout (default: 60) +// breedCooldownBaseSeconds — base breed cooldown, doubles per breed_count (default: 5) +// levelUpFeeLamports — fee per level-up (default: 1_000_000) +// baseMintFeeLamports — base gacha mint fee, escalates per wallet (default: 20_000_000) +// breedFeeLamports — fee per breed commit (default: 10_000_000) +// studFeeLamports — stud fee for cross-owner breed (default: 20_000_000) +// trainFeeLamports — base train fee, scales with level (default: 10_000_000) + +import * as anchor from "@coral-xyz/anchor"; +import { globalStatePda } from "../tests/utils"; + +const PROGRAM_ID = new anchor.web3.PublicKey( + process.env.PROGRAM_ID ?? "88HGagCw4i3BTMHEpdQy3YLeHrkTSKgXmvq66HJXKM7k", +); + +const KEY_TO_INSTRUCTION: Record = { + proposalTtlSeconds: "setProposalTtlSeconds", + marriageCooldownSeconds: "setMarriageCooldownSeconds", + battleCooldownSeconds: "setBattleCooldownSeconds", + trainCooldownSeconds: "setTrainCooldownSeconds", + trainXp: "setTrainXp", + levelBandWidth: "setLevelBandWidth", + maxLevel: "setMaxLevel", + generationCap: "setGenerationCap", + newbornCooldownSeconds: "setNewbornCooldownSeconds", + breedCooldownBaseSeconds: "setBreedCooldownBaseSeconds", + levelUpFeeLamports: "setLevelUpFeeLamports", + baseMintFeeLamports: "setBaseMintFeeLamports", + breedFeeLamports: "setBreedFeeLamports", + studFeeLamports: "setStudFeeLamports", + trainFeeLamports: "setTrainFeeLamports", +}; + +async function main() { + const [key, rawValue] = process.argv.slice(2); + + if (!key || rawValue === undefined) { + console.error("Usage: tsx scripts/set-config.ts "); + console.error("Example: tsx scripts/set-config.ts proposalTtlSeconds 86400"); + console.error("\nAvailable keys:", Object.keys(KEY_TO_INSTRUCTION).join(", ")); + process.exit(1); + } + + const instructionName = KEY_TO_INSTRUCTION[key]; + if (!instructionName) { + console.error(`Unknown key: "${key}". Available: ${Object.keys(KEY_TO_INSTRUCTION).join(", ")}`); + process.exit(1); + } + + const value = Number(rawValue); + if (!Number.isFinite(value) || value < 0) { + console.error(`Value must be a non-negative number, got: "${rawValue}"`); + process.exit(1); + } + + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + const wallet = provider.wallet as anchor.Wallet; + + const idl = await anchor.Program.fetchIdl(PROGRAM_ID, provider); + if (!idl) { + throw new Error(`IDL not found on-chain for ${PROGRAM_ID.toBase58()}.`); + } + const program = new anchor.Program(idl, provider); + const [globalState] = globalStatePda(PROGRAM_ID); + + const gs = await (program.account as any).globalState.fetch(globalState); + + console.log("program :", PROGRAM_ID.toBase58()); + console.log("admin :", wallet.publicKey.toBase58()); + console.log("cluster :", provider.connection.rpcEndpoint); + console.log(`setting : ${key} = ${value} (current: ${gs[key] ?? "?"})`) + + const methods = program.methods as any; + if (typeof methods[instructionName] !== "function") { + throw new Error(`Instruction "${instructionName}" not found in IDL. Is the program up-to-date?`); + } + + const bnValue = new anchor.BN(value); + const sig = await methods[instructionName](bnValue) + .accounts({ globalState, admin: wallet.publicKey }) + .rpc(); + + console.log("\n✅ Done. tx:", sig); + + const updated = await (program.account as any).globalState.fetch(globalState); + console.log(` ${key} is now: ${updated[key]}`); +} + +main().catch((err) => { + console.error("\n❌ set-config failed:", err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/frontend/src/components/pet/interactions/panels/breed/index.tsx b/frontend/src/components/pet/interactions/panels/breed/index.tsx index d638ce46..1ab74d9b 100644 --- a/frontend/src/components/pet/interactions/panels/breed/index.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/index.tsx @@ -83,11 +83,18 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { } }, [pets.length]); - // Auto-select the only pet in the "With Spouse" tab so the user doesn't - // have to pick from a single-item dropdown. + // Auto-select in the "With Spouse" tab: + // 1. Prefer the first pet that already carries spouseId (Solana on-chain field). + // 2. Fall back to the only pet when there is just one (EVM — marriage detected + // lazily via useMarriageInfo after selection). useEffect(() => { - if (tab === 'spouse' && pets.length === 1 && !spousePetId) { - setSpousePetId(pets[0].id); + if (tab === 'spouse' && !spousePetId) { + const marriedPet = pets.find(p => p.spouseId != null && p.spouseId !== 0); + if (marriedPet) { + setSpousePetId(marriedPet.id); + } else if (pets.length === 1) { + setSpousePetId(pets[0].id); + } } }, [tab, pets, spousePetId]); @@ -292,7 +299,7 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { {allPets.map(({ id, pet }) => ( ))} From 541661da819c9ed243c8e64f24c0b95dbebbd7a3 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sun, 21 Jun 2026 16:23:25 -0400 Subject: [PATCH 30/48] test: update breed panel tests for married-pet auto-select and spouse indicator --- .../components/pet/panels/breed.test.tsx | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/frontend/tests/components/pet/panels/breed.test.tsx b/frontend/tests/components/pet/panels/breed.test.tsx index 5b618f2f..3688688d 100644 --- a/frontend/tests/components/pet/panels/breed.test.tsx +++ b/frontend/tests/components/pet/panels/breed.test.tsx @@ -27,11 +27,12 @@ const breed = { }; let capturedOnSuccess: ((arg: { name: string }) => void) | undefined; +const DEFAULT_PETS = [ + { id: '1', name: 'Alpha', level: 2 }, + { id: '2', name: 'Beta', level: 5 }, +]; const petList = { - pets: [ - { id: '1', name: 'Alpha', level: 2 }, - { id: '2', name: 'Beta', level: 5 }, - ], + pets: [...DEFAULT_PETS] as Array<{ id: string; name: string; level: number; spouseId?: number }>, refetch: vi.fn(), }; const capabilities = { randomness: { provider: 'vrf' }, kind: 'solana' }; @@ -46,7 +47,11 @@ vi.mock('@shared/core', () => ({ formatAmount: (v: bigint) => `${v}`, formatAmountOnly: (v: bigint) => String(v), }), - useMarriageInfo: () => ({ isMarried: false, spouseId: undefined }), + useApiClient: () => ({ defaults: { baseURL: '' }, post: vi.fn() }), + useMarriageInfo: (pet?: { spouseId?: number }) => + pet?.spouseId + ? { isMarried: true, spouseId: BigInt(pet.spouseId), isLoading: false, hasProposal: false, refetch: vi.fn() } + : { isMarried: false, spouseId: undefined, isLoading: false, hasProposal: false, refetch: vi.fn() }, usePendingBreed: () => ({ isPending: false }), usePetsConfig: () => ({ evm: undefined }), useBreedPets: (opts: { onSuccess?: (arg: { name: string }) => void }) => { @@ -54,10 +59,17 @@ vi.mock('@shared/core', () => ({ return breed; }, })); -// New sibling that reaches into PetsConfig/wagmi — stub it out. + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: undefined, isLoading: false, error: null }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); vi.mock('@components/pet/interactions/panels/breed/pending-breed-notice', () => ({ default: () => null, })); +vi.mock('@components/pet/interactions/panels/breed/stud-fee-balance', () => ({ + default: () => null, +})); import BreedPanel from '@components/pet/interactions/panels/breed'; @@ -72,9 +84,10 @@ beforeEach(() => { vi.clearAllMocks(); capabilities.randomness.provider = 'vrf'; Object.assign(breed, { isPending: false, isAwaitingFulfillment: false, hash: undefined }); + petList.pets = [...DEFAULT_PETS]; }); -describe('BreedPanel', () => { +describe('BreedPanel — My Pets tab', () => { it('lists parents and excludes the first parent from the second select', async () => { render(); @@ -133,7 +146,68 @@ describe('BreedPanel', () => { it('resets when breed.reset is called', () => { render(); act(() => { breed.reset(); }); - // breed.reset is wired through capturedOnSuccess callback; just verify no crash expect(breed.reset).toBeDefined(); }); }); + +describe('BreedPanel — With Spouse tab', () => { + it('shows ↔ spouse indicator in the dropdown for married Solana pets', async () => { + petList.pets = [ + { id: '1', name: 'Alpha', level: 2, spouseId: 5 }, + { id: '2', name: 'Beta', level: 3 }, + ]; + render(); + + await userEvent.click(screen.getByRole('button', { name: /With Spouse/ })); + + const select = screen.getByRole('combobox'); + expect(within(select).getByRole('option', { name: 'Alpha (Lv 2) ↔ #5' })).toBeInTheDocument(); + expect(within(select).getByRole('option', { name: 'Beta (Lv 3)' })).toBeInTheDocument(); + }); + + it('auto-selects the first married pet when switching to With Spouse tab', async () => { + petList.pets = [ + { id: '1', name: 'Alpha', level: 2, spouseId: 5 }, + { id: '2', name: 'Beta', level: 3 }, + ]; + render(); + + await userEvent.click(screen.getByRole('button', { name: /With Spouse/ })); + + expect(screen.getByRole('combobox')).toHaveValue('1'); + }); + + it('shows the partner pet id once a married pet is selected', async () => { + // Single married pet → auto-switches to spouse tab and auto-selects the pet + petList.pets = [{ id: '1', name: 'Alpha', level: 2, spouseId: 7 }]; + render(); + + // SpouseLabel falls back to "#7" when the GraphQL name fetch has no data yet + expect(screen.getByText('#7')).toBeInTheDocument(); + }); + + it('shows Not married hint when an unmarried pet is selected', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /With Spouse/ })); + await userEvent.selectOptions(screen.getByRole('combobox'), '1'); + + expect(screen.getByText('This pet is not married yet.')).toBeInTheDocument(); + }); + + it('submits a cross-owner breed with the spouse as second parent', async () => { + petList.pets = [{ id: '1', name: 'Alpha', level: 2, spouseId: 9 }]; + render(); + + // Alpha is auto-selected; partner #9 resolved via useMarriageInfo + await userEvent.type(screen.getByPlaceholderText('Name for the new pet…'), 'Cub'); + await userEvent.click(screen.getByRole('button', { name: 'Breed with Spouse' })); + + expect(breed.mutate).toHaveBeenCalledWith({ + parentId1: '1', + parentId2: '9', + name: 'Cub', + crossOwner: true, + }); + }); +}); From 17c5eec8504fc9b56f731c7bf8523ef319730974 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Sun, 21 Jun 2026 16:38:40 -0400 Subject: [PATCH 31/48] test: fix pre-existing CI failures in battle-setup and level-up tests --- frontend/tests/components/pet/battle/battle-setup.test.tsx | 5 ++++- frontend/tests/components/pet/panels/level-up.test.tsx | 1 + shared/src/hooks/useStudFees.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/tests/components/pet/battle/battle-setup.test.tsx b/frontend/tests/components/pet/battle/battle-setup.test.tsx index 12bd5c2c..369e8b34 100644 --- a/frontend/tests/components/pet/battle/battle-setup.test.tsx +++ b/frontend/tests/components/pet/battle/battle-setup.test.tsx @@ -25,10 +25,13 @@ vi.mock('@components/common', () => ({ ), })); -// New sibling that reaches into PetsConfig/wagmi — stub it out. +// Siblings that reach into PetsConfig/wagmi/Anchor — stub them out. vi.mock('@components/pet/interactions/panels/battle/parts/pending-battle-notice', () => ({ default: () => null, })); +vi.mock('@components/pet/interactions/panels/battle/parts/open-to-challenges-toggle', () => ({ + default: () => null, +})); import BattleSetup, { type BattleSetupProps, diff --git a/frontend/tests/components/pet/panels/level-up.test.tsx b/frontend/tests/components/pet/panels/level-up.test.tsx index 19ee518d..027957e6 100644 --- a/frontend/tests/components/pet/panels/level-up.test.tsx +++ b/frontend/tests/components/pet/panels/level-up.test.tsx @@ -43,6 +43,7 @@ vi.mock('@shared/core', () => ({ capturedOnSuccess = opts?.onSuccess; return levelUpPet; }, + useSyncMetadata: () => ({ sync: vi.fn(), isPending: false, error: null }), })); import LevelUpPanel from '@components/pet/interactions/panels/level-up'; diff --git a/shared/src/hooks/useStudFees.ts b/shared/src/hooks/useStudFees.ts index 811d05b0..a3ab6c3c 100644 --- a/shared/src/hooks/useStudFees.ts +++ b/shared/src/hooks/useStudFees.ts @@ -58,7 +58,7 @@ export const useStudFees = (): UseStudFeesResult => { amountLamports, isLoading: query.isLoading, withdraw: { - run: actions.withdrawStudFees.mutateAsync, + run: async () => { await actions.withdrawStudFees.mutateAsync(); }, isPending: actions.withdrawStudFees.isPending, error: actions.withdrawStudFees.error as Error | null, }, From f76e0415171f9d2c8a3184fe2a30a7d0a6fadfbd Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 10:18:00 -0400 Subject: [PATCH 32/48] chore: prune unused deps and tidy marriage/account-dropdown --- frontend/package.json | 4 --- .../interactions/panels/marriage/index.tsx | 23 ++++++--------- .../wallet/account-dropdown/index.tsx | 12 ++++---- pnpm-lock.yaml | 28 ++----------------- 4 files changed, 18 insertions(+), 49 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 0c7eafe6..d9bf83f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,20 +17,17 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@coral-xyz/anchor": "0.32.1", "@dynamic-labs/ethereum": "^4.37.1", "@dynamic-labs/sdk-react-core": "^4.37.1", "@dynamic-labs/solana": "^4.37.1", "@dynamic-labs/wagmi-connector": "^4.37.1", "@shared/core": "workspace:*", - "@solana/wallet-adapter-base": "^0.9.23", "@solana/wallet-adapter-react": "^0.15.35", "@solana/wallet-adapter-react-ui": "^0.9.35", "@solana/wallet-adapter-wallets": "^0.19.32", "@solana/web3.js": "^1.95.2", "@tanstack/react-query": "^5.90.5", "@types/react-modal": "^3.16.3", - "axios": "^1.12.2", "clsx": "^2.1.1", "react": "19.1.1", "react-dom": "19.1.1", @@ -46,7 +43,6 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/axios": "^0.14.4", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "^8.44.0", diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.tsx b/frontend/src/components/pet/interactions/panels/marriage/index.tsx index ab3ef5ca..3432c996 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/index.tsx @@ -96,6 +96,14 @@ const MarriageCard: React.FC<{ ); }; +const formatExpiry = (expirySec: number) => { + const diff = expirySec - Math.floor(Date.now() / 1000); + if (diff <= 0) return 'Expired'; + if (diff < 3600) return `${Math.ceil(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; + return `${Math.floor(diff / 86400)}d`; +}; + /** Shows a single outgoing proposal row in the Propose tab. */ const OutgoingProposalRow: React.FC<{ pet: Pet; @@ -110,12 +118,7 @@ const OutgoingProposalRow: React.FC<{ if (!isOwn) return null; const expirySec = info.proposalExpiry ? Number(info.proposalExpiry) : 0; - const diff = expirySec - Math.floor(Date.now() / 1000); - const expiryLabel = - diff <= 0 ? 'Expired' - : diff < 3600 ? `${Math.ceil(diff / 60)}m` - : diff < 86400 ? `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m` - : `${Math.floor(diff / 86400)}d`; + const expiryLabel = formatExpiry(expirySec); return (
  • @@ -138,14 +141,6 @@ const OutgoingProposalRow: React.FC<{ ); }; -const formatExpiry = (expirySec: number) => { - const diff = expirySec - Math.floor(Date.now() / 1000); - if (diff <= 0) return 'Expired'; - if (diff < 3600) return `${Math.ceil(diff / 60)}m`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; - return `${Math.floor(diff / 86400)}d`; -}; - type PendingAccept = { proposal: IncomingProposal; myPetId: string; diff --git a/frontend/src/components/wallet/account-dropdown/index.tsx b/frontend/src/components/wallet/account-dropdown/index.tsx index a94b3854..0d08c72e 100644 --- a/frontend/src/components/wallet/account-dropdown/index.tsx +++ b/frontend/src/components/wallet/account-dropdown/index.tsx @@ -41,7 +41,7 @@ const AccountDropdown: React.FC = () => { const { publicKey: solanaPublicKey, connected: solanaConnected, disconnect: solanaDisconnect } = useWallet(); const { setShowAuthFlow, handleLogOut, user, primaryWallet } = useDynamicContext(); const [isOpen, setIsOpen] = useState(false); - const [isCopied, setIsCopied] = useState(false); + const [copiedAddress, setCopiedAddress] = useState(null); const [tokenStatus, setTokenStatus] = useState>({}); const [isTokensLoading, setIsTokensLoading] = useState(false); @@ -70,8 +70,8 @@ const AccountDropdown: React.FC = () => { document.execCommand('copy'); document.body.removeChild(textArea); } - setIsCopied(true); - globalThis.setTimeout(() => setIsCopied(false), 2000); + setCopiedAddress(text); + globalThis.setTimeout(() => setCopiedAddress(null), 2000); }, []); useEffect(() => { @@ -217,14 +217,14 @@ const AccountDropdown: React.FC = () => { {address && ( void handleCopyAny(address)} /> )} {solanaPublicKey && ( void handleCopyAny(solanaPublicKey.toString())} /> )} @@ -233,7 +233,7 @@ const AccountDropdown: React.FC = () => { dynamicWalletAddress !== solanaPublicKey?.toString() && ( void handleCopyAny(dynamicWalletAddress)} /> )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd05233..b814576f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,9 +200,6 @@ importers: frontend: dependencies: - '@coral-xyz/anchor': - specifier: 0.32.1 - version: 0.32.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10) '@dynamic-labs/ethereum': specifier: ^4.37.1 version: 4.40.1(@react-native-async-storage/async-storage@1.24.0(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10)))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(immer@10.0.2)(ioredis@5.11.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(viem@2.38.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.4.3))(zod@4.4.3) @@ -218,9 +215,6 @@ importers: '@shared/core': specifier: workspace:* version: link:../shared - '@solana/wallet-adapter-base': - specifier: ^0.9.23 - version: 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10)) '@solana/wallet-adapter-react': specifier: ^0.15.35 version: 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.8.3)(utf-8-validate@5.0.10))(bs58@5.0.0)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.82.0(@babel/core@7.28.5)(@react-native-community/cli@20.0.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10))(@react-native/metro-config@0.82.0(@babel/core@7.28.5)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.1.1)(utf-8-validate@5.0.10))(react@19.1.1)(typescript@5.8.3) @@ -239,9 +233,6 @@ importers: '@types/react-modal': specifier: ^3.16.3 version: 3.16.3 - axios: - specifier: ^1.12.2 - version: 1.12.2 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -282,9 +273,6 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) - '@types/axios': - specifier: ^0.14.4 - version: 0.14.4 '@types/react': specifier: ^19.1.13 version: 19.2.2 @@ -3102,7 +3090,7 @@ packages: '@paulmillr/qr@0.2.1': resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} - deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr' + deprecated: 'The package is now available as "qr": npm install qr' '@pinax/graph-networks-registry@0.7.1': resolution: {integrity: sha512-Gn2kXRiEd5COAaMY/aDCRO0V+zfb1uQKCu5HFPoWka+EsZW27AlTINA7JctYYYEMuCbjMia5FBOzskjgEvj6LA==} @@ -4741,10 +4729,6 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - '@types/axios@0.14.4': - resolution: {integrity: sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==} - deprecated: This is a stub types definition. axios provides its own type definitions, so you do not need this installed. - '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -18725,12 +18709,6 @@ snapshots: '@types/aria-query@5.0.4': {} - '@types/axios@0.14.4': - dependencies: - axios: 1.12.2 - transitivePeerDependencies: - - debug - '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -24743,7 +24721,7 @@ snapshots: isomorphic-timers-promises@1.0.1: {} - isomorphic-ws@4.0.1(ws@7.5.10): + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)): dependencies: ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -24867,7 +24845,7 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@7.5.10) + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 8.3.2 From 2a23ab97f0f09c37abcf176263db8bddabcb540b Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 15:02:25 -0400 Subject: [PATCH 33/48] refactor: move formatExpiry to shared/utils/common/time --- .../pet/interactions/panels/marriage/index.tsx | 9 +-------- shared/src/utils/common/index.ts | 1 + shared/src/utils/common/time.ts | 8 ++++++++ shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c | 8 ++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 shared/src/utils/common/time.ts create mode 100644 shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.tsx b/frontend/src/components/pet/interactions/panels/marriage/index.tsx index 3432c996..0d3537f7 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/index.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { + formatExpiry, useApiClient, useChainCapabilities, useMarriage, @@ -96,14 +97,6 @@ const MarriageCard: React.FC<{ ); }; -const formatExpiry = (expirySec: number) => { - const diff = expirySec - Math.floor(Date.now() / 1000); - if (diff <= 0) return 'Expired'; - if (diff < 3600) return `${Math.ceil(diff / 60)}m`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; - return `${Math.floor(diff / 86400)}d`; -}; - /** Shows a single outgoing proposal row in the Propose tab. */ const OutgoingProposalRow: React.FC<{ pet: Pet; diff --git a/shared/src/utils/common/index.ts b/shared/src/utils/common/index.ts index 96717b0a..23d938b4 100644 --- a/shared/src/utils/common/index.ts +++ b/shared/src/utils/common/index.ts @@ -1 +1,2 @@ export { sleep } from './sleep'; +export { formatExpiry } from './time'; diff --git a/shared/src/utils/common/time.ts b/shared/src/utils/common/time.ts new file mode 100644 index 00000000..87939fe7 --- /dev/null +++ b/shared/src/utils/common/time.ts @@ -0,0 +1,8 @@ +/** Format a Unix expiry timestamp (seconds) as a short relative-time label. */ +export const formatExpiry = (expirySec: number): string => { + const diff = expirySec - Math.floor(Date.now() / 1000); + if (diff <= 0) return 'Expired'; + if (diff < 3600) return `${Math.ceil(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; + return `${Math.floor(diff / 86400)}d`; +}; diff --git a/shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c b/shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c new file mode 100644 index 00000000..87939fe7 --- /dev/null +++ b/shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c @@ -0,0 +1,8 @@ +/** Format a Unix expiry timestamp (seconds) as a short relative-time label. */ +export const formatExpiry = (expirySec: number): string => { + const diff = expirySec - Math.floor(Date.now() / 1000); + if (diff <= 0) return 'Expired'; + if (diff < 3600) return `${Math.ceil(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; + return `${Math.floor(diff / 86400)}d`; +}; From 490cde46d4b180f504a8de38f8f0b7eb63692771 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 16:23:18 -0400 Subject: [PATCH 34/48] refactor: split marriage panel into focused part components --- .../interactions/panels/marriage/index.tsx | 354 +++--------------- .../marriage/parts/accept-confirm-dialog.tsx | 50 +++ .../panels/marriage/parts/accept-tab.tsx | 38 ++ .../marriage/parts/active-marriages.tsx | 32 ++ .../marriage/parts/incoming-proposal-row.tsx | 33 ++ .../panels/marriage/parts/marriage-card.tsx | 86 +++++ .../marriage/parts/marriage-tab-bar.tsx | 33 ++ .../marriage/parts/outgoing-proposal-row.tsx | 44 +++ .../panels/marriage/parts/propose-tab.tsx | 92 +++++ .../pet/interactions/panels/marriage/types.ts | 12 + .../components/pet/panels/marriage.test.tsx | 7 + 11 files changed, 489 insertions(+), 292 deletions(-) create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/active-marriages.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/incoming-proposal-row.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx create mode 100644 frontend/src/components/pet/interactions/panels/marriage/types.ts diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.tsx b/frontend/src/components/pet/interactions/panels/marriage/index.tsx index 0d3537f7..bb208cf4 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/index.tsx @@ -1,143 +1,23 @@ import React, { useMemo, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { - formatExpiry, - useApiClient, useChainCapabilities, useMarriage, - useMarriageInfo, useAllPets, usePetList, useIncomingProposals, type IncomingProposal, type OpponentPet, - type Pet, - type PetChain, } from '@shared/core'; import { useNotifyError } from '@hooks/useNotifyError'; -import { AuthActionButton } from '@components/common'; import Icon, { CheckIcon } from '@components/ui/icon'; -import PetSearchDropdown from '@components/ui/pet-search-dropdown'; import { Tones } from '@constants/tones'; - -const SPOUSE_GQL = `query SpousePet($chain:String!,$id:String!){pet(chain:$chain,id:$id){id name level}}`; - -/** Direct no-debounce pet lookup by ID — fires immediately on mount. */ -const useSpousePet = ( - chain: PetChain | null, - spouseId: string, - skip: boolean, -): { name?: string; level?: number } => { - const apiClient = useApiClient(); - const baseURL = apiClient.defaults.baseURL ?? ''; - const { data } = useQuery({ - queryKey: ['pet', baseURL, chain, spouseId], - enabled: !skip && Boolean(chain && spouseId && spouseId !== '0'), - queryFn: async () => { - const res = await apiClient.post<{ data?: { pet: { id: string; name: string; level: number } | null } }>( - '/graphql', - { query: SPOUSE_GQL, variables: { chain, id: spouseId } }, - ); - return res.data.data?.pet ?? null; - }, - staleTime: 60_000, - }); - return { name: data?.name, level: data?.level }; -}; - -type MarriageTab = 'propose' | 'accept'; - -export type MarriagePanelProps = { - isStandaloneView?: boolean; -}; - -/** Romantic marriage card — shows both pets connected by a heart. */ -const MarriageCard: React.FC<{ - pet: Pet; - chain: PetChain | null; - petById: Map; - onDivorce: (petId: string) => void; - busy: boolean; -}> = ({ pet, chain, petById, onDivorce, busy }) => { - const info = useMarriageInfo(pet); - - const spouseId = info.isMarried && info.spouseId ? info.spouseId.toString() : ''; - const fromMap = spouseId ? petById.get(spouseId) : undefined; - - // Direct no-debounce fallback: single pet(chain, id) query fires immediately - // when the bulk allPets map doesn't have this pet yet. - const fetched = useSpousePet(chain, spouseId, Boolean(fromMap)); - - if (!info.isMarried || !spouseId) return null; - - const spouseName = fromMap?.name ?? fetched.name ?? `#${spouseId}`; - const spouseLevel = fromMap?.level ?? fetched.level; - - return ( -
  • -
    -
    - {pet.name} - #{pet.id} · Lv {pet.level} -
    - -
    - {spouseName} - #{spouseId}{spouseLevel != null ? ` · Lv ${spouseLevel}` : ''} -
    -
    - onDivorce(pet.id)} - disabled={busy} - > - Divorce - -
  • - ); -}; - -/** Shows a single outgoing proposal row in the Propose tab. */ -const OutgoingProposalRow: React.FC<{ - pet: Pet; - walletAddress: string | null; - onCancel: (petId: string) => void; - busy: boolean; -}> = ({ pet, walletAddress, onCancel, busy }) => { - const info = useMarriageInfo(pet); - const isOwn = - info.hasProposal && walletAddress != null && - info.proposer?.toLowerCase() === walletAddress.toLowerCase(); - if (!isOwn) return null; - - const expirySec = info.proposalExpiry ? Number(info.proposalExpiry) : 0; - const expiryLabel = formatExpiry(expirySec); - - return ( -
  • -
    - {pet.name} #{pet.id} - - #{info.proposalPetIdB?.toString()} -
    -
    - Expires {expiryLabel} - onCancel(pet.id)} - disabled={busy} - > - Cancel - -
    -
  • - ); -}; - -type PendingAccept = { - proposal: IncomingProposal; - myPetId: string; -}; +import MarriageTabBar from './parts/marriage-tab-bar'; +import ProposeTab from './parts/propose-tab'; +import AcceptTab from './parts/accept-tab'; +import ActiveMarriages from './parts/active-marriages'; +import AcceptConfirmDialog from './parts/accept-confirm-dialog'; +import type { MarriagePanelProps, MarriageTab, PendingAccept } from './types'; const MarriagePanel: React.FC = ({ isStandaloneView = true }) => { const { kind, activeKind, walletAddress } = useChainCapabilities(); @@ -147,8 +27,6 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } const queryClient = useQueryClient(); const [tab, setTab] = useState('propose'); - const [myPet, setMyPet] = useState(''); - const [partnerId, setPartnerId] = useState(''); const [pendingAccept, setPendingAccept] = useState(null); const [success, setSuccess] = useState(null); @@ -173,7 +51,9 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } const busy = marriage.propose.isPending || marriage.accept.isPending || marriage.cancel.isPending || marriage.divorce.isPending; - const run = async (fn: () => Promise, message: string, onSuccess?: () => void) => { + /** Run a marriage write, surface success/error, and refresh on-chain reads. + * Resolves true on success so callers can reset their own UI. */ + const run = async (fn: () => Promise, message: string): Promise => { setSuccess(null); try { await fn(); @@ -185,32 +65,41 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } void queryClient.invalidateQueries({ queryKey: ['readContract'] }); void queryClient.invalidateQueries({ queryKey: ['readContracts'] }); void queryClient.invalidateQueries({ queryKey: ['incomingProposals'] }); - onSuccess?.(); + return true; } catch (err) { console.error('[marriage]', err); notifyError('Marriage action failed', err, 'marriage'); + return false; } }; - const handleConfirmAccept = () => { - if (!pendingAccept) return; - const { proposal, myPetId } = pendingAccept; - void run( - () => marriage.accept.mutateAsync({ petIdA: proposal.proposerPetId, petIdB: myPetId }), - 'Marriage accepted!', - () => setPendingAccept(null), - ); - }; - if (kind === 'none') { return

    Connect a wallet to use marriage.

    ; } const targetPetName = (id: string) => chainPets.find((p) => p.id === id)?.name ?? `#${id}`; + const handlePropose = (petIdA: string, petIdB: string) => + run(() => marriage.propose.mutateAsync({ petIdA, petIdB }), 'Proposal sent!'); + const handleCancel = (id: string) => void run(() => marriage.cancel.mutateAsync({ petIdA: id }), 'Proposal cancelled.'); + const handleDivorce = (id: string) => + void run(() => marriage.divorce.mutateAsync({ petId: id }), 'Divorced.'); + + const handleConfirmAccept = () => { + if (!pendingAccept) return; + const { proposal, myPetId } = pendingAccept; + void run( + () => marriage.accept.mutateAsync({ petIdA: proposal.proposerPetId, petIdB: myPetId }), + 'Marriage accepted!', + ).then((ok) => { if (ok) setPendingAccept(null); }); + }; + + const openAccept = (proposal: IncomingProposal) => + setPendingAccept({ proposal, myPetId: proposal.targetPetId }); + return ( <>
    @@ -221,169 +110,50 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } )} - {/* Tab bar */} -
    - - -
    + - {/* Propose tab */} {tab === 'propose' && ( -
    -

    Select one of your pets, then search for your partner's pet to send a marriage proposal.

    -
    -
    - - -
    -
    - - -
    - void run( - () => marriage.propose.mutateAsync({ petIdA: myPet, petIdB: partnerId }), - 'Proposal sent!', - () => { setMyPet(''); setPartnerId(''); }, - )} - > - {marriage.propose.isPending ? 'Proposing...' : '💍 Send Proposal'} - -
    - - {/* Sent proposals list */} - {chainPets.length > 0 && ( -
    - Sent proposals -
      - {chainPets.map((p) => ( - - ))} -
    -
    - )} -
    + )} - {/* Accept tab */} {tab === 'accept' && ( -
    -

    Pending proposals from other players to marry one of your pets.

    - - {incomingLoading ? ( -
    Checking for proposals…
    - ) : incomingProposals.length === 0 ? ( -
    No pending proposals for your pets.
    - ) : ( -
      - {incomingProposals.map((p) => ( -
    • -
      - {p.proposerPetName} #{p.proposerPetId} - - your {targetPetName(p.targetPetId)} #{p.targetPetId} -
      -
      - Expires {formatExpiry(p.expiry)} - -
      -
    • - ))} -
    - )} -
    + )} - {/* Active marriages — always visible */} {chainPets.length > 0 && ( -
    - ❤ Your marriages -
      - {chainPets.map((p) => ( - void run(() => marriage.divorce.mutateAsync({ petId: id }), 'Divorced.')} - /> - ))} -
    -
    + )}
    - {/* Confirm accept modal */} {pendingAccept && ( -
    setPendingAccept(null)}> -
    e.stopPropagation()}> -
    💒 Accept Proposal?
    -

    - {pendingAccept.proposal.proposerPetName} (#{pendingAccept.proposal.proposerPetId}) will marry your {targetPetName(pendingAccept.myPetId)} (#{pendingAccept.myPetId}). -

    -
    - - -
    -
    -
    + setPendingAccept(null)} + onConfirm={handleConfirmAccept} + /> )} {success && ( diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx new file mode 100644 index 00000000..b8353e32 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import type { PendingAccept } from '../types'; + +type AcceptConfirmDialogProps = { + pending: PendingAccept; + targetPetName: (id: string) => string; + busy: boolean; + isAccepting: boolean; + onCancel: () => void; + onConfirm: () => void; +}; + +/** Modal confirming acceptance of an incoming proposal. */ +const AcceptConfirmDialog: React.FC = ({ + pending, + targetPetName, + busy, + isAccepting, + onCancel, + onConfirm, +}) => ( +
    +
    e.stopPropagation()}> +
    💒 Accept Proposal?
    +

    + {pending.proposal.proposerPetName} (#{pending.proposal.proposerPetId}) will marry your {targetPetName(pending.myPetId)} (#{pending.myPetId}). +

    +
    + + +
    +
    +
    +); + +export default AcceptConfirmDialog; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx new file mode 100644 index 00000000..dac42643 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { type IncomingProposal } from '@shared/core'; +import IncomingProposalRow from './incoming-proposal-row'; + +type AcceptTabProps = { + proposals: IncomingProposal[]; + isLoading: boolean; + busy: boolean; + targetPetName: (id: string) => string; + onAccept: (proposal: IncomingProposal) => void; +}; + +/** Pending proposals from other players to marry one of the user's pets. */ +const AcceptTab: React.FC = ({ proposals, isLoading, busy, targetPetName, onAccept }) => ( +
    +

    Pending proposals from other players to marry one of your pets.

    + + {isLoading ? ( +
    Checking for proposals…
    + ) : proposals.length === 0 ? ( +
    No pending proposals for your pets.
    + ) : ( +
      + {proposals.map((p) => ( + + ))} +
    + )} +
    +); + +export default AcceptTab; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/active-marriages.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/active-marriages.tsx new file mode 100644 index 00000000..8b78c639 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/active-marriages.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { type OpponentPet, type Pet, type PetChain } from '@shared/core'; +import MarriageCard from './marriage-card'; + +type ActiveMarriagesProps = { + chainPets: Pet[]; + chain: PetChain | null; + petById: Map; + busy: boolean; + onDivorce: (petId: string) => void; +}; + +/** "Your marriages" section — one card per married pet (others render nothing). */ +const ActiveMarriages: React.FC = ({ chainPets, chain, petById, busy, onDivorce }) => ( +
    + ❤ Your marriages +
      + {chainPets.map((p) => ( + + ))} +
    +
    +); + +export default ActiveMarriages; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/incoming-proposal-row.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/incoming-proposal-row.tsx new file mode 100644 index 00000000..d209074f --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/incoming-proposal-row.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { formatExpiry, type IncomingProposal } from '@shared/core'; + +type IncomingProposalRowProps = { + proposal: IncomingProposal; + targetPetName: (id: string) => string; + busy: boolean; + onAccept: (proposal: IncomingProposal) => void; +}; + +/** A single incoming proposal row in the Accept tab. */ +const IncomingProposalRow: React.FC = ({ proposal, targetPetName, busy, onAccept }) => ( +
  • +
    + {proposal.proposerPetName} #{proposal.proposerPetId} + + your {targetPetName(proposal.targetPetId)} #{proposal.targetPetId} +
    +
    + Expires {formatExpiry(proposal.expiry)} + +
    +
  • +); + +export default IncomingProposalRow; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx new file mode 100644 index 00000000..6f06f834 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + useApiClient, + useMarriageInfo, + type OpponentPet, + type Pet, + type PetChain, +} from '@shared/core'; +import { AuthActionButton } from '@components/common'; + +const SPOUSE_GQL = `query SpousePet($chain:String!,$id:String!){pet(chain:$chain,id:$id){id name level}}`; + +/** Direct no-debounce pet lookup by ID — fires immediately on mount. */ +const useSpousePet = ( + chain: PetChain | null, + spouseId: string, + skip: boolean, +): { name?: string; level?: number } => { + const apiClient = useApiClient(); + const baseURL = apiClient.defaults.baseURL ?? ''; + const { data } = useQuery({ + queryKey: ['pet', baseURL, chain, spouseId], + enabled: !skip && Boolean(chain && spouseId && spouseId !== '0'), + queryFn: async () => { + const res = await apiClient.post<{ data?: { pet: { id: string; name: string; level: number } | null } }>( + '/graphql', + { query: SPOUSE_GQL, variables: { chain, id: spouseId } }, + ); + return res.data.data?.pet ?? null; + }, + staleTime: 60_000, + }); + return { name: data?.name, level: data?.level }; +}; + +type MarriageCardProps = { + pet: Pet; + chain: PetChain | null; + petById: Map; + onDivorce: (petId: string) => void; + busy: boolean; +}; + +/** Romantic marriage card — shows both pets connected by a heart. Renders nothing + * unless this pet is married. */ +const MarriageCard: React.FC = ({ pet, chain, petById, onDivorce, busy }) => { + const info = useMarriageInfo(pet); + + const spouseId = info.isMarried && info.spouseId ? info.spouseId.toString() : ''; + const fromMap = spouseId ? petById.get(spouseId) : undefined; + + // Direct no-debounce fallback: single pet(chain, id) query fires immediately + // when the bulk allPets map doesn't have this pet yet. + const fetched = useSpousePet(chain, spouseId, Boolean(fromMap)); + + if (!info.isMarried || !spouseId) return null; + + const spouseName = fromMap?.name ?? fetched.name ?? `#${spouseId}`; + const spouseLevel = fromMap?.level ?? fetched.level; + + return ( +
  • +
    +
    + {pet.name} + #{pet.id} · Lv {pet.level} +
    + +
    + {spouseName} + #{spouseId}{spouseLevel != null ? ` · Lv ${spouseLevel}` : ''} +
    +
    + onDivorce(pet.id)} + disabled={busy} + > + Divorce + +
  • + ); +}; + +export default MarriageCard; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx new file mode 100644 index 00000000..423ce9b5 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import type { MarriageTab } from '../types'; + +type MarriageTabBarProps = { + tab: MarriageTab; + onChange: (tab: MarriageTab) => void; + proposalCount: number; +}; + +/** Propose / Accept switcher with a badge for pending incoming proposals. */ +const MarriageTabBar: React.FC = ({ tab, onChange, proposalCount }) => ( +
    + + +
    +); + +export default MarriageTabBar; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx new file mode 100644 index 00000000..6eaa8120 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { formatExpiry, useMarriageInfo, type Pet } from '@shared/core'; +import { AuthActionButton } from '@components/common'; + +type OutgoingProposalRowProps = { + pet: Pet; + walletAddress: string | null; + onCancel: (petId: string) => void; + busy: boolean; +}; + +/** A single outgoing proposal row in the Propose tab. Renders nothing unless this + * pet has a pending proposal owned by the connected wallet. */ +const OutgoingProposalRow: React.FC = ({ pet, walletAddress, onCancel, busy }) => { + const info = useMarriageInfo(pet); + const isOwn = + info.hasProposal && walletAddress != null && + info.proposer?.toLowerCase() === walletAddress.toLowerCase(); + if (!isOwn) return null; + + const expirySec = info.proposalExpiry ? Number(info.proposalExpiry) : 0; + + return ( +
  • +
    + {pet.name} #{pet.id} + + #{info.proposalPetIdB?.toString()} +
    +
    + Expires {formatExpiry(expirySec)} + onCancel(pet.id)} + disabled={busy} + > + Cancel + +
    +
  • + ); +}; + +export default OutgoingProposalRow; diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx new file mode 100644 index 00000000..734e4aef --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { type Pet, type PetChain } from '@shared/core'; +import { AuthActionButton } from '@components/common'; +import PetSearchDropdown from '@components/ui/pet-search-dropdown'; +import OutgoingProposalRow from './outgoing-proposal-row'; + +type ProposeTabProps = { + chainPets: Pet[]; + chain: PetChain | null; + walletAddress: string | null; + busy: boolean; + isProposing: boolean; + /** Resolves true on success so the form can reset itself. */ + onPropose: (petIdA: string, petIdB: string) => Promise; + onCancelProposal: (petId: string) => void; +}; + +/** Compose a new proposal and review proposals already sent. */ +const ProposeTab: React.FC = ({ + chainPets, + chain, + walletAddress, + busy, + isProposing, + onPropose, + onCancelProposal, +}) => { + const [myPet, setMyPet] = useState(''); + const [partnerId, setPartnerId] = useState(''); + + const handlePropose = async () => { + const ok = await onPropose(myPet, partnerId); + if (ok) { + setMyPet(''); + setPartnerId(''); + } + }; + + return ( +
    +

    Select one of your pets, then search for your partner's pet to send a marriage proposal.

    +
    +
    + + +
    +
    + + +
    + void handlePropose()} + > + {isProposing ? 'Proposing...' : '💍 Send Proposal'} + +
    + + {chainPets.length > 0 && ( +
    + Sent proposals +
      + {chainPets.map((p) => ( + + ))} +
    +
    + )} +
    + ); +}; + +export default ProposeTab; diff --git a/frontend/src/components/pet/interactions/panels/marriage/types.ts b/frontend/src/components/pet/interactions/panels/marriage/types.ts new file mode 100644 index 00000000..19d561ef --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/types.ts @@ -0,0 +1,12 @@ +import type { IncomingProposal } from '@shared/core'; + +export type MarriageTab = 'propose' | 'accept'; + +export type MarriagePanelProps = { + isStandaloneView?: boolean; +}; + +export type PendingAccept = { + proposal: IncomingProposal; + myPetId: string; +}; diff --git a/frontend/tests/components/pet/panels/marriage.test.tsx b/frontend/tests/components/pet/panels/marriage.test.tsx index 0f8be4f4..9ee797d6 100644 --- a/frontend/tests/components/pet/panels/marriage.test.tsx +++ b/frontend/tests/components/pet/panels/marriage.test.tsx @@ -45,6 +45,13 @@ const marriageInfo = { isMarried: false, hasProposal: false, spouseId: undefined let incomingProposals: { proposerPetId: string; proposerPetName: string; proposerOwner: string; targetPetId: string; expiry: number }[] = []; vi.mock('@shared/core', () => ({ + formatExpiry: (expirySec: number) => { + const diff = expirySec - Math.floor(Date.now() / 1000); + if (diff <= 0) return 'Expired'; + if (diff < 3600) return `${Math.ceil(diff / 60)}m`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; + return `${Math.floor(diff / 86400)}d`; + }, useAuth: () => ({ isAuthenticated: true, isSigning: false, isVerifying: false, isNonceLoading: false, signAndLogin: vi.fn() }), useChainCapabilities: () => capabilities, usePetList: () => petList, From bb49df93e01148ac1add5ea58a9b0006bde3dc95 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 17:18:45 -0400 Subject: [PATCH 35/48] chore(frontend): add prettier + editorconfig and reformat src --- frontend/.editorconfig | 12 + frontend/.prettierignore | 3 + frontend/.prettierrc.json | 7 + frontend/CODE_QUALITY_REFACTOR.md | 297 +++++++++++ frontend/package.json | 3 + frontend/src/App.css | 14 +- frontend/src/chains/ethereum/wagmi.ts | 2 +- frontend/src/chains/solana/anchor-wallet.tsx | 20 +- frontend/src/chains/solana/auth-signer.tsx | 13 +- frontend/src/chains/solana/provider.tsx | 5 +- .../chains/solana/useDynamicSolanaWallet.ts | 4 +- .../common/auth-action-button/index.tsx | 4 +- .../common/dashboard-panel/index.css | 8 +- .../common/dashboard-panel/index.tsx | 7 +- .../common/transaction-status/index.css | 4 +- frontend/src/components/layout/index.css | 466 +++++++++--------- frontend/src/components/layout/index.tsx | 38 +- .../pet/collection/pet-gallery/index.css | 139 +++--- .../pet/collection/pet-gallery/index.tsx | 146 ++++-- .../pet/creation/create-pet-modal/index.css | 16 +- .../pet/creation/create-pet-modal/index.tsx | 46 +- .../pet/interactions/overview/index.css | 155 +++--- .../pet/interactions/overview/index.tsx | 148 ++++-- .../panels/battle/battle-dialogue.css | 3 +- .../panels/battle/battle-matchmaking.ts | 26 +- .../panels/battle/battle-result-art.tsx | 109 +++- .../panels/battle/battle-utils.ts | 3 +- .../pet/interactions/panels/battle/index.css | 93 ++-- .../pet/interactions/panels/battle/index.tsx | 6 +- .../panels/battle/parts/battle-overlay.tsx | 26 +- .../panels/battle/parts/battle-setup.tsx | 68 ++- .../battle/parts/fighter-picker-card.tsx | 12 +- .../battle/parts/opponent-picker-card.tsx | 9 +- .../battle/parts/pending-battle-notice.tsx | 12 +- .../pet/interactions/panels/breed/index.tsx | 136 +++-- .../panels/breed/pending-breed-notice.tsx | 17 +- .../interactions/panels/level-up/index.tsx | 16 +- .../interactions/panels/marriage/index.tsx | 19 +- .../marriage/parts/accept-confirm-dialog.tsx | 11 +- .../panels/marriage/parts/accept-tab.tsx | 12 +- .../marriage/parts/active-marriages.tsx | 8 +- .../marriage/parts/incoming-proposal-row.tsx | 17 +- .../panels/marriage/parts/marriage-card.tsx | 20 +- .../marriage/parts/marriage-tab-bar.tsx | 4 +- .../marriage/parts/outgoing-proposal-row.tsx | 14 +- .../panels/marriage/parts/propose-tab.tsx | 9 +- .../pet/interactions/panels/rename/index.tsx | 32 +- .../pet/interactions/panels/train/index.tsx | 19 +- .../pet/interactions/standalone/index.tsx | 41 +- .../pet/transfer/send-pet-modal/index.css | 12 +- .../pet/transfer/send-pet-modal/index.tsx | 45 +- frontend/src/components/ui/icon/index.css | 58 ++- frontend/src/components/ui/icon/index.tsx | 135 ++--- .../src/components/ui/neon-button/index.css | 197 ++++---- .../src/components/ui/neon-button/index.tsx | 44 +- .../src/components/ui/neon-card/index.css | 97 ++-- .../src/components/ui/neon-card/index.tsx | 23 +- .../src/components/ui/neon-modal/index.css | 112 +++-- .../src/components/ui/neon-modal/index.tsx | 92 ++-- .../ui/pet-search-dropdown/index.css | 16 +- .../ui/pet-search-dropdown/index.tsx | 85 ++-- frontend/src/components/ui/toast/index.css | 12 +- frontend/src/components/ui/toast/index.tsx | 10 +- .../wallet/account-dropdown/index.css | 4 +- .../wallet/account-dropdown/index.tsx | 109 ++-- .../wallet/native-balance/index.tsx | 15 +- .../wallet/network-switcher/ethereum.tsx | 30 +- .../wallet/network-switcher/index.css | 15 +- .../wallet/network-switcher/solana.tsx | 22 +- frontend/src/constants/chains/ethereum.ts | 34 +- frontend/src/constants/chains/solana.ts | 4 +- frontend/src/constants/interactionRoutes.ts | 71 ++- frontend/src/contexts/dynamic/index.tsx | 38 +- frontend/src/hooks/battle/useBattleOutcome.ts | 35 +- frontend/src/hooks/battle/useBattlePanel.ts | 80 +-- .../src/hooks/battle/useResultDialogue.ts | 23 +- frontend/src/hooks/useNotifyError.ts | 3 +- frontend/src/hooks/usePetError.ts | 4 +- frontend/src/hooks/usePetErrorToast.ts | 11 +- frontend/src/hooks/useTxErrorToast.ts | 2 +- frontend/src/index.css | 99 ++-- frontend/src/main.tsx | 14 +- frontend/src/petsContractParams.ts | 4 +- frontend/src/styles/messages.css | 12 +- frontend/src/styles/variables.css | 301 +++++------ frontend/src/vite-env.d.ts | 2 +- pnpm-lock.yaml | 7 +- .../common/time.ts.tmp.12564.cb12c6a83d4c | 8 - 88 files changed, 2511 insertions(+), 1673 deletions(-) create mode 100644 frontend/.editorconfig create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/CODE_QUALITY_REFACTOR.md delete mode 100644 shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 00000000..f055e1c9 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..007ea8a7 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +coverage diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 00000000..78b736c2 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md new file mode 100644 index 00000000..231f4f0c --- /dev/null +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -0,0 +1,297 @@ +# Frontend Code-Quality Refactor — Plan & Progress + +A step-by-step refactor of `frontend/src` to fix architectural-consistency issues +surfaced in the code-quality review. Work is done **one step per commit**; the user +commits manually after each step. This file is the single source of truth so any +session can resume without re-deriving context. + +## How to use this doc +1. Pick the first step whose status is `TODO`. +2. Do **only** that step. Keep the diff focused. +3. Verify with the step's acceptance criteria (typecheck + lint + tests as noted). +4. Fill in the step's **Outcome** and **Commit message**, flip status to `DONE`. +5. Hand the commit message to the user; they commit manually. + +## Project conventions (apply to every step) +- **Indentation:** 4 spaces (the app majority; see Step 1). +- **Components:** `kebab-case/index.tsx`, `React.FC`, `type XxxProps`, default export. +- **Parts pattern:** complex panels = slim orchestrator + `parts/` presentational + components (see `panels/battle` and the refactored `panels/marriage` as templates). +- **Hooks:** `useXxx.ts`, camelCase. +- **Verify commands** (run from `frontend/`): + - Typecheck: `node ../node_modules/typescript/bin/tsc -b` + - Lint: `node ../node_modules/eslint/bin/eslint.js ` + - Tests: `node ../node_modules/vitest/vitest.mjs run ` +- **Commit style:** Conventional Commits; trailer + `Co-Authored-By: Claude Opus 4.8 `. + +## Reference templates already in the codebase +- Headless controller: `src/hooks/battle/useBattlePanel.ts` +- Parts decomposition: `src/components/pet/interactions/panels/battle/parts/` +- Recently refactored example: `src/components/pet/interactions/panels/marriage/` + +--- + +## Status overview +| # | Step | Status | +|---|------|--------| +| 1 | Tooling: Prettier + `.editorconfig` + reformat | DONE | +| 2 | Colocate panel CSS (split `overview/index.css`) | TODO | +| 3 | Extract shared `useSpousePet` hook | TODO | +| 4 | Decompose `breed` panel into orchestrator + parts | TODO | +| 5 | Extract `pet-gallery` cooldown logic into a hook | TODO | +| 6 | Design-system decision + button consolidation | TODO | +| 7 | Shared accessible modal + migrate bespoke modals | TODO | +| 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | + +Statuses: `TODO` → `IN PROGRESS` → `DONE` (or `SKIPPED` with reason). + +--- + +## Step 1 — Tooling: Prettier + `.editorconfig` + reformat +**Status:** DONE + +**Goal:** Eliminate the 2-space (`ui/neon-*`) vs 4-space (rest) inconsistency and +prevent future drift. + +**Why:** `eslint.config.js` has no Prettier and no `indent` rule; no `.editorconfig` +exists. Nothing enforces style today. + +**Plan:** +- Add Prettier config (4-space, single quotes, trailing commas — match existing majority). +- Add `.editorconfig` (4-space, LF, final newline). +- Wire a `format`/`format:check` script in `frontend/package.json`. +- Run a one-shot reformat across `src/`. This is a **large, whitespace-only diff** — + keep it isolated in its own commit so later steps stay reviewable. + +**Acceptance:** +- `format:check` passes clean. +- Typecheck + lint pass. +- Diff is whitespace/formatting only (no logic changes). + +**Outcome (done):** +- Settings chosen: `tabWidth: 4`, `singleQuote: true`, `semi: true`, + `trailingComma: 'all'`, `printWidth: 100`. +- Added `frontend/.prettierrc.json`, `frontend/.prettierignore`, `frontend/.editorconfig`. +- Added `format` / `format:check` scripts; pinned `prettier` to `2.8.8` in devDependencies + (the version already present in the monorepo store — avoids a fresh download). +- Reformatted all of `src/**/*.{ts,tsx,css}`. The `ui/neon-*` 2-space files are now 4-space; + some long lines rewrapped at width 100. +- **Verified:** `format:check` clean, `tsc -b` 0 errors, `eslint .` 0, **272/272 tests pass**. +- **Lockfile note:** `pnpm add` hit the known Windows EBUSY lock-rename issue + ([[windows-file-lock-renames]]); applied the copy-temp-over workaround so the root + `pnpm-lock.yaml` now records `prettier` as a frontend devDep. The same install also + normalized an unrelated `isomorphic-ws` peer-dep notation (pnpm re-resolution; harmless, + would appear on anyone's next install). +- **Prettier version caveat:** pinned to 2.8.8 (old, 2023) because installing 3.x failed on + the EBUSY lock. Upgrading to Prettier 3.x is a good future follow-up once installs work. + +**Commit message:** +``` +chore(frontend): add prettier + editorconfig and reformat src + +Standardize formatting (4-space, single quotes, semicolons, trailing +commas, printWidth 100) to remove the 2-space/4-space inconsistency between +the neon-* UI components and the rest of the app. Add .prettierrc.json, +.prettierignore, .editorconfig, and format/format:check scripts; pin +prettier to the version already in the monorepo store. + +Whitespace/formatting only — no logic changes. Typecheck, lint, and all +272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` + +--- + +## Step 2 — Colocate panel CSS (split `overview/index.css`) +**Status:** TODO + +**Goal:** Each panel owns and imports its own stylesheet; remove cross-component +CSS coupling. + +**Why:** `overview/index.css` is ~1281 lines holding styles for marriage, rename, +train, level-up **and** the hub. `standalone/index.tsx:10` imports a sibling's CSS +(`overview/index.css`) just to style those panels — fragile. `battle/` and `breed/` +already colocate correctly; bring the rest in line. + +**Plan:** +- Identify selectors per panel (`.marriage-*`, `.proposal-*`, `.rename-*`, `.train-*`, + `.level-up-*`, plus shared `.interface`, `.action-controls`, `.picker`, `.field`, + `.success-message`). +- Move panel-specific blocks into colocated `index.css` files imported by each panel. +- Keep genuinely shared/hub styles in `overview/index.css` (or promote shared ones to + `styles/`). Decide: shared form primitives (`.interface`, `.field`, `.picker`) likely + belong in `styles/` so all panels + standalone get them without importing a sibling. +- Remove the `overview/index.css` import from `standalone/index.tsx` once styles resolve + via each panel + shared styles. + +**Acceptance:** +- Each panel route (dashboard hub **and** standalone `/marriage`, `/breed`, etc.) renders + visually unchanged. Verify in-app (the `run`/`verify` skills). +- No component imports another component's `index.css`. +- Typecheck + lint + tests pass. + +**Risk:** CSS regressions are visual; verify each standalone page and the hub. + +**Outcome:** _(fill after completion)_ + +**Commit message:** _(fill after completion)_ + +--- + +## Step 3 — Extract shared `useSpousePet` hook +**Status:** TODO + +**Goal:** One spouse-name-by-id lookup hook, reused by marriage and breed. + +**Why:** Duplicated GraphQL lookups: +- `panels/marriage/parts/marriage-card.tsx:13` — `useSpousePet` + `SPOUSE_GQL` +- `panels/breed/index.tsx:38` — `SpouseLabel` + `SPOUSE_NAME_GQL` +Both run `useQuery(['pet', baseURL, chain, id])`. + +**Plan:** +- Add a shared `useSpousePet(chain, id, opts?)` returning `{ name, level }` in + `@shared/core` (next to other pet hooks) — it's now wanted by 2 features, so the + shared package is the right home. Confirm the existing query key shape so caches + dedupe across both call sites. +- Update `marriage-card.tsx` to consume it; drop the local copy + `SPOUSE_GQL`. +- (`breed` itself is migrated in Step 4; this step just lands the hook + marriage swap.) + +**Acceptance:** +- Marriage cards still resolve spouse name/level. +- No behavior change; typecheck + lint + marriage tests pass. + +**Outcome:** _(fill after completion)_ + +**Commit message:** _(fill after completion)_ + +--- + +## Step 4 — Decompose `breed` panel into orchestrator + parts +**Status:** TODO + +**Goal:** Reduce `panels/breed/index.tsx` (~403 lines) to a slim orchestrator plus +`parts/`, mirroring the marriage refactor. + +**Why:** Same monolith pattern just fixed in marriage: two tabs of state, contract +relative-detection logic, pending-breed logic, and all JSX in one file. + +**Plan:** +- `types.ts`: `BreedTab`, `BreedPanelProps`. +- Consider a headless `useBreedPanel` hook (battle-style) for the contract/relative/ + pending logic if it stays heavy after splitting JSX. +- `parts/`: `breed-tab-bar`, `own-pets-tab`, `with-spouse-tab`, `offspring-name-input`, + `breed-submit` (reuse existing `pending-breed-notice`, `stud-fee-balance`). +- Replace the local `SpouseLabel` with the shared `useSpousePet` from Step 3. +- Move tab-local state into the relevant tab components where it isn't shared. + +**Acceptance:** +- Both tabs (own / with-spouse), relative warning, pending-breed notices, stud fee, + and submit all behave identically. Verify in-app. +- Typecheck + lint + any breed tests pass. + +**Outcome:** _(fill after completion)_ + +**Commit message:** _(fill after completion)_ + +--- + +## Step 5 — Extract `pet-gallery` cooldown logic into a hook +**Status:** TODO + +**Goal:** Move readiness/cooldown computation out of the view. + +**Why:** `pet-gallery/index.tsx:51` runs a manual 1s `setInterval` re-render tick and +inlines readiness math (`!isPetReady(BigInt(p.readyAt)) || ...`) duplicated at lines +~53–55 and ~206–208. + +**Plan:** +- Add `usePetCooldowns(pets)` (or similar) returning per-pet readiness flags + labels + and owning the tick interval. Place in `src/hooks/` (or `@shared/core` if reused). +- View consumes the hook and just renders. + +**Acceptance:** +- Cooldown countdowns still tick live; ready/on-cooldown states unchanged. +- Typecheck + lint pass. + +**Outcome:** _(fill after completion)_ + +**Commit message:** _(fill after completion)_ + +--- + +## Step 6 — Design-system decision + button consolidation +**Status:** TODO + +**Goal:** One button story across the app. + +**Why:** `ui/neon-*` exists but is used **only in `wallet/`**. Elsewhere: raw +` ) : null} diff --git a/frontend/src/components/common/transaction-status/index.css b/frontend/src/components/common/transaction-status/index.css index 13227d7d..5162fed9 100644 --- a/frontend/src/components/common/transaction-status/index.css +++ b/frontend/src/components/common/transaction-status/index.css @@ -12,9 +12,7 @@ border-radius: 2px; border: 1px solid var(--neon-border-soft); background: linear-gradient(180deg, rgb(10 16 32 / 96%), rgb(6 10 22 / 98%)); - box-shadow: - inset 0 0 18px rgb(125 214 255 / 8%), - 0 0 20px rgb(125 214 255 / 20%), + box-shadow: inset 0 0 18px rgb(125 214 255 / 8%), 0 0 20px rgb(125 214 255 / 20%), 0 16px 40px rgb(0 0 0 / 45%); backdrop-filter: blur(8px); animation: tx-slide-in var(--tx-slide-duration) ease-out; diff --git a/frontend/src/components/layout/index.css b/frontend/src/components/layout/index.css index 996ab83a..4f91d11c 100644 --- a/frontend/src/components/layout/index.css +++ b/frontend/src/components/layout/index.css @@ -1,163 +1,165 @@ /* Theme + layout tokens: frontend/src/styles/variables.css */ .main-container { - width: 100%; - height: 100vh; - font-family: var(--content-font); - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - background: var(--neon-gradient-wash); - color: var(--color-text); + width: 100%; + height: 100vh; + font-family: var(--content-font); + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--neon-gradient-wash); + color: var(--color-text); } .main-header { - position: fixed; - inset-block-start: 0; - inset-inline: 0; - width: 100%; - background: rgb(5 8 18 / 68%); - backdrop-filter: blur(10px); - border-block-end: 1px solid var(--neon-border-soft); - z-index: var(--z-header); - box-shadow: 0 10px 26px rgb(0 0 0 / 32%); - box-sizing: border-box; - min-height: var(--main-header-offset); - padding-inline: clamp(20px, 5vw, 42px); - padding-block: 10px; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - justify-content: space-between; - gap: 16px; - - & h1 { - font-family: var(--title-font); - font-size: clamp(1.8rem, 3vw, 2.5rem); - line-height: 1.2; - margin: 0; - color: #f4f2ff; - letter-spacing: 1.2px; - font-weight: 700; - text-transform: uppercase; - text-shadow: 0 0 12px var(--neon-text-glow); - } - - & .title { - display: block; - position: relative; - flex: 1 1 auto; - min-width: 0; - text-align: start; + position: fixed; + inset-block-start: 0; + inset-inline: 0; + width: 100%; + background: rgb(5 8 18 / 68%); + backdrop-filter: blur(10px); + border-block-end: 1px solid var(--neon-border-soft); + z-index: var(--z-header); + box-shadow: 0 10px 26px rgb(0 0 0 / 32%); + box-sizing: border-box; + min-height: var(--main-header-offset); + padding-inline: clamp(20px, 5vw, 42px); + padding-block: 10px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + gap: 16px; - &::after { - content: ''; - position: absolute; - left: 0; - top: 50%; - width: clamp(140px, 22vw, 260px); - height: 42px; - transform: translateY(-50%); - background: radial-gradient(ellipse at center, rgb(138 98 255 / 35%) 0%, rgb(74 128 255 / 16%) 48%, transparent 78%); - filter: blur(8px); - pointer-events: none; - z-index: 0; + & h1 { + font-family: var(--title-font); + font-size: clamp(1.8rem, 3vw, 2.5rem); + line-height: 1.2; + margin: 0; + color: #f4f2ff; + letter-spacing: 1.2px; + font-weight: 700; + text-transform: uppercase; + text-shadow: 0 0 12px var(--neon-text-glow); } - & h1 { - position: relative; - z-index: 1; - text-shadow: - 0 0 10px rgb(185 160 255 / 50%), - 0 0 22px rgb(125 141 255 / 38%), - 0 0 44px rgb(108 72 255 / 26%); + & .title { + display: block; + position: relative; + flex: 1 1 auto; + min-width: 0; + text-align: start; + + &::after { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: clamp(140px, 22vw, 260px); + height: 42px; + transform: translateY(-50%); + background: radial-gradient( + ellipse at center, + rgb(138 98 255 / 35%) 0%, + rgb(74 128 255 / 16%) 48%, + transparent 78% + ); + filter: blur(8px); + pointer-events: none; + z-index: 0; + } + + & h1 { + position: relative; + z-index: 1; + text-shadow: 0 0 10px rgb(185 160 255 / 50%), 0 0 22px rgb(125 141 255 / 38%), + 0 0 44px rgb(108 72 255 / 26%); + } } - } - & .account-dropdown { - position: relative; - top: auto; - right: auto; - left: auto; - display: flex; - justify-content: flex-end; - align-items: center; - width: auto; - max-width: 100%; - } + & .account-dropdown { + position: relative; + top: auto; + right: auto; + left: auto; + display: flex; + justify-content: flex-end; + align-items: center; + width: auto; + max-width: 100%; + } - @media (max-width: 768px) { - padding-inline: clamp(14px, 4vw, 22px); - padding-block: 10px; - gap: 12px; - flex-wrap: wrap; + @media (max-width: 768px) { + padding-inline: clamp(14px, 4vw, 22px); + padding-block: 10px; + gap: 12px; + flex-wrap: wrap; - & h1 { - font-size: 1.3rem; + & h1 { + font-size: 1.3rem; + } } - } } .wallet-section { - display: flex; - justify-content: flex-end; - align-items: center; - flex: 0 0 auto; - gap: 8px; - flex-wrap: wrap; - - @media (max-width: 768px) { - max-width: 100%; - justify-content: center; - } + display: flex; + justify-content: flex-end; + align-items: center; + flex: 0 0 auto; + gap: 8px; + flex-wrap: wrap; + + @media (max-width: 768px) { + max-width: 100%; + justify-content: center; + } } .main-content { - flex: 1; - margin-block-start: var(--main-header-offset); - width: 100%; - box-sizing: border-box; - margin-inline: 0; - overflow-y: auto; - overflow-x: hidden; - - /* Landing: gap under fixed header can be painted by hero section. */ - &:has(.landing) { - margin-block-start: 0; - background-color: #070a14; - } + flex: 1; + margin-block-start: var(--main-header-offset); + width: 100%; + box-sizing: border-box; + margin-inline: 0; + overflow-y: auto; + overflow-x: hidden; + + /* Landing: gap under fixed header can be painted by hero section. */ + &:has(.landing) { + margin-block-start: 0; + background-color: #070a14; + } } .main-content { - /* Fill exactly the viewport below the fixed header; never scroll the page itself. */ - height: calc(100vh - var(--main-header-offset)); - padding: var(--spacing-md) var(--spacing-lg); - display: flex; - flex-direction: column; - gap: var(--spacing-md); - text-align: start; - background: - repeating-linear-gradient(0deg, var(--neon-grid-line) 0 1px, transparent 1px 48px), - repeating-linear-gradient(90deg, var(--neon-grid-line) 0 1px, transparent 1px 48px), - linear-gradient(180deg, var(--neon-base-bg) 0%, var(--neon-band-bg) 100%); - overflow: hidden; - min-height: 0; - box-sizing: border-box; - - & > * { - width: 100%; - max-width: none; + /* Fill exactly the viewport below the fixed header; never scroll the page itself. */ + height: calc(100vh - var(--main-header-offset)); + padding: var(--spacing-md) var(--spacing-lg); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + text-align: start; + background: repeating-linear-gradient(0deg, var(--neon-grid-line) 0 1px, transparent 1px 48px), + repeating-linear-gradient(90deg, var(--neon-grid-line) 0 1px, transparent 1px 48px), + linear-gradient(180deg, var(--neon-base-bg) 0%, var(--neon-band-bg) 100%); + overflow: hidden; min-height: 0; - min-width: 0; - } + box-sizing: border-box; - & > .dashboard-panel { - flex: 1 1 auto; - min-height: 0; - display: flex; - } + & > * { + width: 100%; + max-width: none; + min-height: 0; + min-width: 0; + } + + & > .dashboard-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + } } /* The interactions panel hosts tall content (battle setup, breed, etc.) whose @@ -167,121 +169,121 @@ reachable. Applies in every layout that shows this panel (standalone or beside the gallery); the stacked mobile dashboard re-overrides this to `visible` below. */ .dashboard-panel.pet-interactions .panel-body { - overflow-x: clip; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; + overflow-x: clip; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--neon-cyan) transparent; } /* Dashboard view (interactions hub + pet gallery): split horizontally 50/50 so both panels share equal width and the pet cards render in full. */ .main-content:has(.dashboard-panel.pet-collection) { - flex-direction: row; - align-items: stretch; - - & > .dashboard-panel { - flex: 1 1 0; - width: auto; - max-width: none; - min-width: 0; - height: 100%; - margin: 0; - display: flex; - flex-direction: column; - overflow: hidden; - } - - & > .dashboard-panel.pet-interactions { - & .surface { - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; + flex-direction: row; + align-items: stretch; + + & > .dashboard-panel { + flex: 1 1 0; + width: auto; + max-width: none; + min-width: 0; + height: 100%; + margin: 0; + display: flex; + flex-direction: column; + overflow: hidden; } - & .surface::-webkit-scrollbar { - width: 6px; - } - - & .surface::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, var(--neon-cyan), var(--neon-violet)); - border-radius: 3px; - box-shadow: 0 0 8px rgb(125 214 255 / 32%); - } - - & .action-buttons { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-auto-rows: min-content; - align-items: start; - flex: 0 0 auto; - } - - & .action-buttons > .breeding-lab-card, - & .action-buttons > .battle-arena-card, - & .action-buttons > .feature-action-card { - flex: 0 0 auto; - min-height: 0; - height: auto; + & > .dashboard-panel.pet-interactions { + & .surface { + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--neon-cyan) transparent; + } + + & .surface::-webkit-scrollbar { + width: 6px; + } + + & .surface::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--neon-cyan), var(--neon-violet)); + border-radius: 3px; + box-shadow: 0 0 8px rgb(125 214 255 / 32%); + } + + & .action-buttons { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: min-content; + align-items: start; + flex: 0 0 auto; + } + + & .action-buttons > .breeding-lab-card, + & .action-buttons > .battle-arena-card, + & .action-buttons > .feature-action-card { + flex: 0 0 auto; + min-height: 0; + height: auto; + } } - } - & > .dashboard-panel.pet-collection { - & .panel-body { - min-height: 0; + & > .dashboard-panel.pet-collection { + & .panel-body { + min-height: 0; + } } - } } @media (max-width: 1024px) { - .main-content { - padding: var(--spacing-sm) var(--spacing-md); - gap: var(--spacing-sm); - } + .main-content { + padding: var(--spacing-sm) var(--spacing-md); + gap: var(--spacing-sm); + } - /* Stack panels vertically on narrow screens; allow the page to scroll + /* Stack panels vertically on narrow screens; allow the page to scroll so both interactions and gallery remain usable. */ - .main-content:has(.dashboard-panel.pet-collection) { - flex-direction: column; - overflow-y: auto; - overflow-x: hidden; - - & > .dashboard-panel.pet-interactions { - flex: 0 0 auto; - width: 100%; - max-width: none; - height: auto; - overflow: visible; - - & .surface { - overflow: visible; - } - - /* Stacked layout scrolls the whole page (main-content), so the panel body + .main-content:has(.dashboard-panel.pet-collection) { + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; + + & > .dashboard-panel.pet-interactions { + flex: 0 0 auto; + width: 100%; + max-width: none; + height: auto; + overflow: visible; + + & .surface { + overflow: visible; + } + + /* Stacked layout scrolls the whole page (main-content), so the panel body should grow with its content rather than scroll on its own. */ - & .panel-body { - overflow: visible; - } - - & .action-buttons { - display: grid; - flex: 0 0 auto; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - - & > .dashboard-panel.pet-collection { - flex: 1 1 auto; - width: 100%; - min-height: 480px; + & .panel-body { + overflow: visible; + } + + & .action-buttons { + display: grid; + flex: 0 0 auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + & > .dashboard-panel.pet-collection { + flex: 1 1 auto; + width: 100%; + min-height: 480px; + } } - } } - @media (max-width: 640px) { - .main-content:has(.dashboard-panel.pet-collection) - > .dashboard-panel.pet-interactions .action-buttons { - display: grid; - grid-template-columns: 1fr; - } + .main-content:has(.dashboard-panel.pet-collection) + > .dashboard-panel.pet-interactions + .action-buttons { + display: grid; + grid-template-columns: 1fr; + } } diff --git a/frontend/src/components/layout/index.tsx b/frontend/src/components/layout/index.tsx index db0b2e49..37fa2377 100644 --- a/frontend/src/components/layout/index.tsx +++ b/frontend/src/components/layout/index.tsx @@ -10,28 +10,28 @@ import './index.css'; const TITLE = 'Crypto Pets'; const Layout: React.FC = () => { - const location = useLocation(); - const isGalleryHidden = isInteractionRoute(location.pathname); + const location = useLocation(); + const isGalleryHidden = isInteractionRoute(location.pathname); - return ( -
    -
    -
    -

    {TITLE}

    -
    -
    - -
    -
    + return ( +
    +
    +
    +

    {TITLE}

    +
    +
    + +
    +
    -
    - - {!isGalleryHidden && } -
    +
    + + {!isGalleryHidden && } +
    - -
    - ); + +
    + ); }; export default Layout; diff --git a/frontend/src/components/pet/collection/pet-gallery/index.css b/frontend/src/components/pet/collection/pet-gallery/index.css index af3b6a98..4be3fe15 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.css @@ -55,16 +55,12 @@ cursor: pointer; margin-block-start: var(--spacing-sm); transition: var(--transition); - box-shadow: - inset 0 0 12px rgb(255 110 196 / 14%), - 0 0 12px rgb(255 110 196 / 18%); + box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 18%); &:hover { transform: translateY(-1px); border-color: var(--neon-magenta); - box-shadow: - inset 0 0 16px rgb(255 110 196 / 22%), - 0 0 18px rgb(255 110 196 / 32%); + box-shadow: inset 0 0 16px rgb(255 110 196 / 22%), 0 0 18px rgb(255 110 196 / 32%); } } @@ -101,18 +97,14 @@ & .ring-outer { inset: 0; border: 1px dashed rgb(125 214 255 / 28%); - box-shadow: - inset 0 0 30px rgb(125 214 255 / 8%), - 0 0 22px rgb(125 214 255 / 14%); + box-shadow: inset 0 0 30px rgb(125 214 255 / 8%), 0 0 22px rgb(125 214 255 / 14%); animation: altar-spin 22s linear infinite; } & .ring-mid { inset: 18%; border: 1px solid rgb(181 140 255 / 32%); - box-shadow: - inset 0 0 24px rgb(181 140 255 / 12%), - 0 0 20px rgb(181 140 255 / 20%); + box-shadow: inset 0 0 24px rgb(181 140 255 / 12%), 0 0 20px rgb(181 140 255 / 20%); animation: altar-spin 16s linear infinite reverse; } @@ -120,9 +112,7 @@ inset: 32%; border: 1px solid rgb(255 110 196 / 30%); background: radial-gradient(circle, rgb(181 140 255 / 22%), transparent 70%); - box-shadow: - inset 0 0 22px rgb(255 110 196 / 14%), - 0 0 26px rgb(181 140 255 / 28%); + box-shadow: inset 0 0 22px rgb(255 110 196 / 14%), 0 0 26px rgb(181 140 255 / 28%); animation: altar-pulse 3.6s ease-in-out infinite; } @@ -143,10 +133,26 @@ animation: altar-float 5s ease-in-out infinite; } - & .orb-tl { top: 6%; left: 8%; animation-delay: -0.2s; } - & .orb-tr { top: 8%; right: 6%; animation-delay: -1.1s; } - & .orb-bl { bottom: 10%; left: 10%; animation-delay: -2.4s; } - & .orb-br { bottom: 8%; right: 8%; animation-delay: -3.3s; } + & .orb-tl { + top: 6%; + left: 8%; + animation-delay: -0.2s; + } + & .orb-tr { + top: 8%; + right: 6%; + animation-delay: -1.1s; + } + & .orb-bl { + bottom: 10%; + left: 10%; + animation-delay: -2.4s; + } + & .orb-br { + bottom: 8%; + right: 8%; + animation-delay: -3.3s; + } & .empty-copy { display: flex; @@ -184,17 +190,31 @@ } @keyframes altar-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } @keyframes altar-pulse { - 0%, 100% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.06); opacity: 0.85; } + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.06); + opacity: 0.85; + } } @keyframes altar-float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-4px); } + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } } @media (prefers-reduced-motion: reduce) { @@ -217,18 +237,14 @@ transition: var(--transition); text-transform: uppercase; letter-spacing: 0.95px; - box-shadow: - inset 0 0 12px rgb(125 214 255 / 16%), - 0 0 12px rgb(125 214 255 / 20%); + box-shadow: inset 0 0 12px rgb(125 214 255 / 16%), 0 0 12px rgb(125 214 255 / 20%); flex-shrink: 0; &:hover { transform: translateY(-2px); color: var(--neon-violet); border-color: rgb(181 140 255 / 60%); - box-shadow: - inset 0 0 22px rgb(181 140 255 / 22%), - 0 0 28px rgb(181 140 255 / 38%); + box-shadow: inset 0 0 22px rgb(181 140 255 / 22%), 0 0 28px rgb(181 140 255 / 38%); } &:active { @@ -331,10 +347,8 @@ .pet-visual { position: relative; - background: - radial-gradient(circle at 30% 30%, rgb(125 214 255 / 24%), transparent 60%), - radial-gradient(circle at 80% 80%, rgb(181 140 255 / 22%), transparent 60%), - #0a0f1c; + background: radial-gradient(circle at 30% 30%, rgb(125 214 255 / 24%), transparent 60%), + radial-gradient(circle at 80% 80%, rgb(181 140 255 / 22%), transparent 60%), #0a0f1c; border-block-end: 1px solid var(--neon-border-soft); border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; height: 140px; @@ -394,9 +408,7 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; - box-shadow: - inset 0 0 12px rgb(255 255 255 / 14%), - 0 0 14px rgb(255 255 255 / 22%); + box-shadow: inset 0 0 12px rgb(255 255 255 / 14%), 0 0 14px rgb(255 255 255 / 22%); backdrop-filter: blur(4px); @media (max-width: 480px) { @@ -417,9 +429,7 @@ font-size: var(--font-size-xs); font-weight: 700; letter-spacing: 0.4px; - box-shadow: - inset 0 0 10px rgb(125 214 255 / 14%), - 0 0 10px rgb(125 214 255 / 22%); + box-shadow: inset 0 0 10px rgb(125 214 255 / 14%), 0 0 10px rgb(125 214 255 / 22%); } .element-tag { @@ -433,9 +443,7 @@ border-radius: 999px; font-size: var(--font-size-xs); font-weight: 700; - box-shadow: - inset 0 0 10px rgb(255 110 196 / 12%), - 0 0 10px rgb(255 110 196 / 18%); + box-shadow: inset 0 0 10px rgb(255 110 196 / 12%), 0 0 10px rgb(255 110 196 / 18%); } .skill-badge { @@ -450,9 +458,7 @@ font-size: var(--font-size-xs); font-weight: 700; letter-spacing: 0.4px; - box-shadow: - inset 0 0 10px rgb(255 207 112 / 12%), - 0 0 10px rgb(255 207 112 / 18%); + box-shadow: inset 0 0 10px rgb(255 207 112 / 12%), 0 0 10px rgb(255 207 112 / 18%); } .pet-main-info { @@ -486,10 +492,22 @@ font-variant-numeric: tabular-nums; letter-spacing: 0.3px; - .record-wins { color: rgb(125 214 255 / 75%); font-weight: 700; } - .record-sep { color: rgb(195 210 255 / 30%); } - .record-losses { color: rgb(195 210 255 / 45%); font-weight: 600; } - .record-breeds { color: rgb(195 210 255 / 35%); font-style: italic; margin-left: 4px; } + .record-wins { + color: rgb(125 214 255 / 75%); + font-weight: 700; + } + .record-sep { + color: rgb(195 210 255 / 30%); + } + .record-losses { + color: rgb(195 210 255 / 45%); + font-weight: 600; + } + .record-breeds { + color: rgb(195 210 255 / 35%); + font-style: italic; + margin-left: 4px; + } } .xp-value { @@ -552,7 +570,6 @@ text-shadow: 0 0 6px rgb(181 140 255 / 28%); } - .pet-status { padding: 0 12px; margin-block-start: var(--spacing-xs); @@ -579,9 +596,7 @@ border-radius: 2px; text-transform: uppercase; letter-spacing: 0.6px; - box-shadow: - inset 0 0 12px rgb(255 181 67 / 12%), - 0 0 12px rgb(255 181 67 / 18%); + box-shadow: inset 0 0 12px rgb(255 181 67 / 12%), 0 0 12px rgb(255 181 67 / 18%); text-shadow: 0 0 10px rgb(255 181 67 / 32%); } @@ -617,17 +632,13 @@ /* Default (ready) — emerald: pet is ready for action. */ color: #9effd4; border: 1px solid rgb(0 255 157 / 42%); - box-shadow: - inset 0 0 12px rgb(0 255 157 / 12%), - 0 0 12px rgb(0 255 157 / 18%); + box-shadow: inset 0 0 12px rgb(0 255 157 / 12%), 0 0 12px rgb(0 255 157 / 18%); &.is-ready:hover, &:hover { transform: translateY(-1px); border-color: rgb(0 255 157 / 64%); - box-shadow: - inset 0 0 18px rgb(0 255 157 / 20%), - 0 0 18px rgb(0 255 157 / 32%); + box-shadow: inset 0 0 18px rgb(0 255 157 / 20%), 0 0 18px rgb(0 255 157 / 32%); } &:active { @@ -638,15 +649,11 @@ &.on-cooldown { color: #ffd07b; border-color: rgb(255 181 67 / 44%); - box-shadow: - inset 0 0 12px rgb(255 181 67 / 12%), - 0 0 12px rgb(255 181 67 / 20%); + box-shadow: inset 0 0 12px rgb(255 181 67 / 12%), 0 0 12px rgb(255 181 67 / 20%); &:hover { border-color: rgb(255 181 67 / 70%); - box-shadow: - inset 0 0 18px rgb(255 181 67 / 22%), - 0 0 18px rgb(255 181 67 / 32%); + box-shadow: inset 0 0 18px rgb(255 181 67 / 22%), 0 0 18px rgb(255 181 67 / 32%); } } diff --git a/frontend/src/components/pet/collection/pet-gallery/index.tsx b/frontend/src/components/pet/collection/pet-gallery/index.tsx index 1bf601e4..856d3da9 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/index.tsx @@ -49,10 +49,11 @@ const PetGallery: React.FC = () => { }, [isLoading]); // Tick every second while any pet is on cooldown so the countdown stays live. - const anyCooldown = pets.some((p) => - !isPetReady(BigInt(p.readyAt)) || - (p.breedReadyAt != null && !isPetReady(BigInt(p.breedReadyAt))) || - (p.trainReadyAt != null && !isPetReady(BigInt(p.trainReadyAt))), + const anyCooldown = pets.some( + (p) => + !isPetReady(BigInt(p.readyAt)) || + (p.breedReadyAt != null && !isPetReady(BigInt(p.breedReadyAt))) || + (p.trainReadyAt != null && !isPetReady(BigInt(p.trainReadyAt))), ); useEffect(() => { if (!anyCooldown) return; @@ -79,7 +80,12 @@ const PetGallery: React.FC = () => { return ( Your Pet Collection} + title={ + <> + + Your Pet Collection + + } description="Connect your wallet to view your pets" /> ); @@ -88,7 +94,12 @@ const PetGallery: React.FC = () => { return ( <> Your Pets} + title={ + <> + + Your Pets + + } actions={ @@ -123,11 +137,46 @@ const PetGallery: React.FC = () => { - - - - - + + + + + + + + + + + + + + +

    Awaken your first companion

    @@ -138,7 +187,8 @@ const PetGallery: React.FC = () => { className="create-first-pet-button" onClick={() => setCreateModalOpen(true)} > - Create your first pet + + Create your first pet
    )} @@ -156,7 +206,10 @@ const PetGallery: React.FC = () => {
    {getPetElement(pet.dna)}
    {getPetSkill(pet.speciesId) ? ( -
    +
    {getPetSkill(pet.speciesId)?.name}
    ) : null} @@ -168,7 +221,8 @@ const PetGallery: React.FC = () => {

    {pet.name}

    - {getPetClass(pet.dna)} · Gen {pet.generation ?? getGeneration(pet.dna)} + {getPetClass(pet.dna)} · Gen{' '} + {pet.generation ?? getGeneration(pet.dna)}
    @@ -178,15 +232,22 @@ const PetGallery: React.FC = () => {
    -
    +
    - {(pet.winCount > 0 || pet.lossCount > 0 || (pet.breedCount != null && pet.breedCount > 0)) && ( + {(pet.winCount > 0 || + pet.lossCount > 0 || + (pet.breedCount != null && pet.breedCount > 0)) && (
    {pet.winCount}W / {pet.lossCount}L {pet.breedCount != null && pet.breedCount > 0 && ( - · {pet.breedCount} bred + + · {pet.breedCount} bred + )}
    )} @@ -204,34 +265,53 @@ const PetGallery: React.FC = () => {
    {(!isPetReady(BigInt(pet.readyAt)) || - (pet.breedReadyAt != null && !isPetReady(BigInt(pet.breedReadyAt))) || - (pet.trainReadyAt != null && !isPetReady(BigInt(pet.trainReadyAt)))) && ( + (pet.breedReadyAt != null && + !isPetReady(BigInt(pet.breedReadyAt))) || + (pet.trainReadyAt != null && + !isPetReady(BigInt(pet.trainReadyAt)))) && (
    {!isPetReady(BigInt(pet.readyAt)) && (
    - ⚔️ Battle ready in {getTimeUntilReady(BigInt(pet.readyAt))} -
    - )} - {pet.breedReadyAt != null && !isPetReady(BigInt(pet.breedReadyAt)) && ( -
    - 🥚 Breed ready in {getTimeUntilReady(BigInt(pet.breedReadyAt))} -
    - )} - {pet.trainReadyAt != null && !isPetReady(BigInt(pet.trainReadyAt)) && ( -
    - 💪 Train ready in {getTimeUntilReady(BigInt(pet.trainReadyAt))} + ⚔️ Battle ready in{' '} + {getTimeUntilReady(BigInt(pet.readyAt))}
    )} + {pet.breedReadyAt != null && + !isPetReady(BigInt(pet.breedReadyAt)) && ( +
    + 🥚 Breed ready in{' '} + {getTimeUntilReady(BigInt(pet.breedReadyAt))} +
    + )} + {pet.trainReadyAt != null && + !isPetReady(BigInt(pet.trainReadyAt)) && ( +
    + 💪 Train ready in{' '} + {getTimeUntilReady(BigInt(pet.trainReadyAt))} +
    + )}
    )}
    diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.css b/frontend/src/components/pet/creation/create-pet-modal/index.css index 21a45e05..11760b12 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.css +++ b/frontend/src/components/pet/creation/create-pet-modal/index.css @@ -5,9 +5,7 @@ --modal-max-height: min(90vh, calc(100dvh - var(--main-header-offset) - 2 * var(--spacing-lg))); --close-size: 32px; --backdrop: rgb(3 8 18 / 72%); - --elevated-shadow: - inset 0 0 24px rgb(125 214 255 / 8%), - 0 0 24px rgb(181 140 255 / 22%), + --elevated-shadow: inset 0 0 24px rgb(125 214 255 / 8%), 0 0 24px rgb(181 140 255 / 22%), 0 20px 56px rgb(0 0 0 / 50%); --focus-ring: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); @@ -140,9 +138,7 @@ caret-color: var(--neon-cyan); transition: border-color 0.2s ease, box-shadow 0.2s ease; outline: none; - box-shadow: - inset 0 0 20px rgb(125 214 255 / 4%), - inset 0 1px 0 rgb(125 214 255 / 8%); + box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), inset 0 1px 0 rgb(125 214 255 / 8%); &:hover { border-color: rgb(125 214 255 / 55%); @@ -177,17 +173,13 @@ transition: var(--transition); text-transform: uppercase; letter-spacing: 0.95px; - box-shadow: - inset 0 0 16px rgb(125 214 255 / 18%), - 0 0 16px rgb(125 214 255 / 24%); + box-shadow: inset 0 0 16px rgb(125 214 255 / 18%), 0 0 16px rgb(125 214 255 / 24%); &:hover:not(:disabled) { transform: translateY(-2px); color: var(--neon-violet); border-color: rgb(181 140 255 / 60%); - box-shadow: - inset 0 0 22px rgb(181 140 255 / 22%), - 0 0 28px rgb(181 140 255 / 38%); + box-shadow: inset 0 0 22px rgb(181 140 255 / 22%), 0 0 28px rgb(181 140 255 / 38%); } &:active:not(:disabled) { diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.tsx b/frontend/src/components/pet/creation/create-pet-modal/index.tsx index c8fedf8f..42e87693 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.tsx +++ b/frontend/src/components/pet/creation/create-pet-modal/index.tsx @@ -1,10 +1,6 @@ import React, { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { - useChainCapabilities, - useCreatePet, - useFees, -} from '@shared/core'; +import { useChainCapabilities, useCreatePet, useFees } from '@shared/core'; import { Tones } from '@constants/tones'; import Icon, { CheckIcon, PawIcon } from '@components/ui/icon'; import TransactionStatus from '@components/common/transaction-status'; @@ -41,7 +37,15 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { onClose(); }; - const { mutate, isPending, isAwaitingFulfillment, isSettling, error: hookError, reset, lifecycle } = useCreatePet({ + const { + mutate, + isPending, + isAwaitingFulfillment, + isSettling, + error: hookError, + reset, + lifecycle, + } = useCreatePet({ onSuccess: handleCreateComplete, }); @@ -56,12 +60,12 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { const buttonLabel = isPending ? 'Submitting...' : isAwaitingFulfillment - ? 'Awaiting randomness...' - : isSettling - ? 'Settling mint...' - : feesLoading - ? 'Loading fees...' - : `Create Pet (${mintCost})`; + ? 'Awaiting randomness...' + : isSettling + ? 'Settling mint...' + : feesLoading + ? 'Loading fees...' + : `Create Pet (${mintCost})`; const handleCreatePet = async () => { if (!isConnected) { @@ -98,14 +102,20 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => {
    e.stopPropagation()}>
    -

    Create Your First Pet

    +

    + + Create Your First Pet +

    -

    Give your pet a unique name and bring it to life! You can only create one pet initially — breed to grow your collection!

    +

    + Give your pet a unique name and bring it to life! You can only create one + pet initially — breed to grow your collection! +

    @@ -121,16 +131,16 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { />
    - {mintCost && ( -

    Mint cost: {mintCost}

    - )} + {mintCost &&

    Mint cost: {mintCost}

    } {/* Creating a pet is fully on-chain (Switchboard VRF + program) and needs no backend session — gate on wallet connection only, not SIWS auth. */}
    - Battle Arena + + + Battle Arena + {availableBattles} left
    - {previewParentA?.name ?? 'Fighter A'} + + {previewParentA?.name ?? 'Fighter A'} +
    -
    +
    -
    +
    + +
    VS
    - {previewOpponent?.name ?? 'On-chain rival'} + + {previewOpponent?.name ?? 'On-chain rival'} +
    -
    +
    @@ -185,7 +259,10 @@ const PetInteractions: React.FC = () => {
    -
    Level Up
    +
    + + Level Up +
    Boost your pet stats by leveling up. @@ -204,7 +281,10 @@ const PetInteractions: React.FC = () => {
    -
    Training Ground
    +
    + + Training Ground +
    Train your pet for an instant XP boost. @@ -221,7 +301,10 @@ const PetInteractions: React.FC = () => {
    -
    Marriage
    +
    + + Marriage +
    Marry two pets to unlock cross-owner breeding. @@ -238,7 +321,10 @@ const PetInteractions: React.FC = () => {
    -
    Change Name
    +
    + + Change Name +
    Rename your pet. diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css b/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css index f1eee321..06ee18c6 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css +++ b/frontend/src/components/pet/interactions/panels/battle/battle-dialogue.css @@ -81,7 +81,6 @@ } @keyframes battle-dialogue-pulse { - 0%, 100% { opacity: 0.5; @@ -102,4 +101,4 @@ opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts b/frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts index 7e32fe0a..4f12dc82 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts +++ b/frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts @@ -3,28 +3,34 @@ import type { OpponentPet } from '@shared/core'; export type MatchTier = 'even' | 'easy' | 'risky' | 'danger' | 'unknown'; /** Opponent level minus fighter level; `null` when no fighter is selected. */ -export const getLevelDelta = (fighterLevel: number | null, opponentLevel: number): number | null => { +export const getLevelDelta = ( + fighterLevel: number | null, + opponentLevel: number, +): number | null => { if (fighterLevel == null) return null; return opponentLevel - fighterLevel; -} +}; -export const getMatchTier = (delta: number | null): MatchTier => { +export const getMatchTier = (delta: number | null): MatchTier => { if (delta == null) return 'unknown'; if (delta <= -2) return 'easy'; if (delta >= 4) return 'danger'; if (delta >= 2) return 'risky'; return 'even'; -} +}; -export const getMatchLabel = (tier: MatchTier, delta: number | null): string | null => { +export const getMatchLabel = (tier: MatchTier, delta: number | null): string | null => { if (delta == null || tier === 'unknown') return null; if (tier === 'even' && delta === 0) return 'Even match'; if (delta > 0) return `+${delta} lv`; return `${delta} lv`; -} +}; /** Pick a random opponent whose level is closest to the fighter's. */ -export const pickRandomOpponent = (opponents: OpponentPet[], fighterLevel: number): OpponentPet | null => { +export const pickRandomOpponent = ( + opponents: OpponentPet[], + fighterLevel: number, +): OpponentPet | null => { if (opponents.length === 0) return null; let minDistance = Number.POSITIVE_INFINITY; @@ -35,12 +41,12 @@ export const pickRandomOpponent = (opponents: OpponentPet[], fighterLevel: numbe const pool = opponents.filter((o) => Math.abs(o.level - fighterLevel) === minDistance); return pool[Math.floor(Math.random() * pool.length)] ?? null; -} +}; export const sortOpponentsByMatch = ( opponents: OpponentPet[], fighterLevel: number | null, -): OpponentPet[] => { +): OpponentPet[] => { if (fighterLevel == null) return opponents; return [...opponents].sort((a, b) => { @@ -49,4 +55,4 @@ export const sortOpponentsByMatch = ( if (deltaA !== deltaB) return deltaA - deltaB; return a.level - b.level; }); -} +}; diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx b/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx index 0a6c417d..ae3d0c90 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx @@ -5,42 +5,99 @@ type Props = { outcome: BattleOutcome }; const PendingArt: React.FC = () => ( - - - - - - - - + + + + + + + + ); const VictoryArt: React.FC = () => ( - - - - - - - - - - + + + + + + + + + + ); const DefeatArt: React.FC = () => ( - - - - - - - - + + + + + + + + ); diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts b/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts index 1a3d0e78..a2a17e9d 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts +++ b/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts @@ -20,7 +20,8 @@ export const shortAddress = (addr: string) => export const VALIDATION_MESSAGE = 'Please select your pet and an opponent'; export const BATTLE_FAIL_MESSAGE = 'Failed to start battle. Please try again.'; export const REMATCH_COOLDOWN_MESSAGE = 'Your fighter is on cooldown. Pick another pet or wait.'; -export const REMATCH_OPPONENT_GONE_MESSAGE = 'That opponent is no longer available. Choose another challenger.'; +export const REMATCH_OPPONENT_GONE_MESSAGE = + 'That opponent is no longer available. Choose another challenger.'; /** Personas captured at battle start, reused for the settle dialogue read. */ export type BattlePersonas = { attacker: DialoguePetInput; defender: DialoguePetInput }; diff --git a/frontend/src/components/pet/interactions/panels/battle/index.css b/frontend/src/components/pet/interactions/panels/battle/index.css index 36b58ba7..d3bbdfb1 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.css +++ b/frontend/src/components/pet/interactions/panels/battle/index.css @@ -19,8 +19,13 @@ overflow: hidden; /* Card shell — directional fighter/opponent tint with deep dark base */ - background: - linear-gradient(90deg, rgb(125 214 255 / 5%) 0%, transparent 38%, transparent 62%, rgb(255 110 196 / 5%) 100%), + background: linear-gradient( + 90deg, + rgb(125 214 255 / 5%) 0%, + transparent 38%, + transparent 62%, + rgb(255 110 196 / 5%) 100% + ), radial-gradient(ellipse 100% 120% at 50% 110%, rgb(255 110 196 / 9%), transparent 55%), linear-gradient(170deg, rgb(11 18 40 / 97%), rgb(5 9 22 / 99%)); border: 1px solid rgb(255 110 196 / 26%); @@ -29,11 +34,8 @@ display: flex; flex-direction: column; gap: 12px; - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 4%), - inset 0 0 48px rgb(255 110 196 / 4%), - 0 0 24px rgb(255 110 196 / 8%), - 0 8px 32px rgb(0 0 0 / 30%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(255 110 196 / 4%), + 0 0 24px rgb(255 110 196 / 8%), 0 8px 32px rgb(0 0 0 / 30%); /* Suppress the hub-divider — gap handles spacing */ .hub-divider { @@ -135,11 +137,8 @@ &.is-ready { border-color: rgb(255 110 196 / 42%); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 4%), - inset 0 0 48px rgb(255 110 196 / 6%), - 0 0 32px rgb(255 110 196 / 14%), - 0 8px 32px rgb(0 0 0 / 30%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(255 110 196 / 6%), + 0 0 32px rgb(255 110 196 / 14%), 0 8px 32px rgb(0 0 0 / 30%); .center { .icon { @@ -164,11 +163,8 @@ &.is-result { border-color: rgb(0 255 157 / 40%); - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 4%), - inset 0 0 48px rgb(0 255 157 / 8%), - 0 0 30px rgb(0 255 157 / 16%), - 0 8px 32px rgb(0 0 0 / 30%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(0 255 157 / 8%), + 0 0 30px rgb(0 255 157 / 16%), 0 8px 32px rgb(0 0 0 / 30%); } .content { @@ -195,11 +191,14 @@ align-items: center; justify-content: center; border-radius: 50%; - background: radial-gradient(circle at center, rgb(255 110 196 / 20%), rgb(255 110 196 / 6%) 65%, transparent); + background: radial-gradient( + circle at center, + rgb(255 110 196 / 20%), + rgb(255 110 196 / 6%) 65%, + transparent + ); border: 1px solid rgb(255 110 196 / 34%); - box-shadow: - inset 0 0 14px rgb(255 110 196 / 22%), - 0 0 20px rgb(255 110 196 / 12%); + box-shadow: inset 0 0 14px rgb(255 110 196 / 22%), 0 0 20px rgb(255 110 196 / 12%); } .vs { @@ -491,8 +490,7 @@ &:focus-visible { border-color: rgb(255 110 196 / 85%); - box-shadow: - inset 0 0 0 1px rgb(255 110 196 / 55%), + box-shadow: inset 0 0 0 1px rgb(255 110 196 / 55%), inset 0 0 16px rgb(255 110 196 / 24%); } } @@ -519,8 +517,7 @@ font-size: 1.6rem; line-height: 1; border-radius: 10px; - background: - radial-gradient(circle at 30% 30%, rgb(125 214 255 / 20%), transparent 60%), + background: radial-gradient(circle at 30% 30%, rgb(125 214 255 / 20%), transparent 60%), rgb(5 13 30 / 82%); border: 1px solid rgb(125 214 255 / 18%); } @@ -633,9 +630,7 @@ letter-spacing: 0.7px; text-transform: uppercase; cursor: pointer; - box-shadow: - inset 0 0 12px rgb(255 110 196 / 14%), - 0 0 12px rgb(255 110 196 / 22%); + box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 22%); &:hover:not(:disabled) { transform: translateY(-1px); @@ -670,9 +665,7 @@ border-radius: 16px; padding: 32px 28px 28px; text-align: center; - box-shadow: - inset 0 0 32px rgb(0 255 157 / 8%), - 0 0 40px rgb(0 255 157 / 16%), + box-shadow: inset 0 0 32px rgb(0 255 157 / 8%), 0 0 40px rgb(0 255 157 / 16%), 0 24px 60px rgb(0 0 0 / 55%); animation: battle-result-pop 0.45s cubic-bezier(0.22, 1, 0.36, 1); @@ -722,9 +715,7 @@ &.is-defeat { border-color: rgb(255 110 196 / 42%); - box-shadow: - inset 0 0 32px rgb(255 110 196 / 8%), - 0 0 40px rgb(255 110 196 / 16%), + box-shadow: inset 0 0 32px rgb(255 110 196 / 8%), 0 0 40px rgb(255 110 196 / 16%), 0 24px 60px rgb(0 0 0 / 55%); .art svg { @@ -738,9 +729,7 @@ &.is-pending { border-color: rgb(125 214 255 / 28%); - box-shadow: - inset 0 0 24px rgb(125 214 255 / 5%), - 0 0 28px rgb(125 214 255 / 10%), + box-shadow: inset 0 0 24px rgb(125 214 255 / 5%), 0 0 28px rgb(125 214 255 / 10%), 0 24px 60px rgb(0 0 0 / 55%); .art svg { @@ -767,15 +756,11 @@ background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); border: 1px solid rgb(255 110 196 / 55%); color: #ff9ad6; - box-shadow: - inset 0 0 12px rgb(255 110 196 / 14%), - 0 0 12px rgb(255 110 196 / 22%); + box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 22%); &.is-defeat { border-color: rgb(255 110 196 / 38%); - box-shadow: - inset 0 0 10px rgb(255 110 196 / 10%), - 0 0 8px rgb(255 110 196 / 16%); + box-shadow: inset 0 0 10px rgb(255 110 196 / 10%), 0 0 8px rgb(255 110 196 / 16%); } &:hover:not(:disabled) { @@ -882,19 +867,13 @@ @keyframes battle-arena-clash { 0%, 100% { - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 4%), - inset 0 0 48px rgb(255 110 196 / 4%), - 0 0 24px rgb(255 110 196 / 8%), - 0 8px 32px rgb(0 0 0 / 30%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 48px rgb(255 110 196 / 4%), + 0 0 24px rgb(255 110 196 / 8%), 0 8px 32px rgb(0 0 0 / 30%); } 50% { - box-shadow: - inset 0 1px 0 rgb(255 255 255 / 4%), - inset 0 0 60px rgb(255 110 196 / 20%), - 0 0 40px rgb(255 110 196 / 22%), - 0 8px 32px rgb(0 0 0 / 30%); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 4%), inset 0 0 60px rgb(255 110 196 / 20%), + 0 0 40px rgb(255 110 196 / 22%), 0 8px 32px rgb(0 0 0 / 30%); } } @@ -921,8 +900,12 @@ } @keyframes battle-result-pending-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/components/pet/interactions/panels/battle/index.tsx b/frontend/src/components/pet/interactions/panels/battle/index.tsx index bf76655a..1fadc347 100644 --- a/frontend/src/components/pet/interactions/panels/battle/index.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/index.tsx @@ -18,11 +18,7 @@ const BattlePanel: React.FC = ({ isStandaloneView = true }) => - {hashHint && ( -

    - Transaction: {hashHint} -

    - )} + {hashHint &&

    Transaction: {hashHint}

    } diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx index 03776520..82f64604 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx @@ -65,7 +65,9 @@ const BattleOverlay: React.FC = ({ const resultCardClass = [ 'battle-result-card', battleOutcome === null ? 'is-pending' : isVictory ? '' : 'is-defeat', - ].filter(Boolean).join(' '); + ] + .filter(Boolean) + .join(' '); return (
    @@ -76,16 +78,20 @@ const BattleOverlay: React.FC = ({

    - {battleOutcome === null ? 'Resolving…' : isVictory ? 'Victory!' : 'Defeated'} + {battleOutcome === null + ? 'Resolving…' + : isVictory + ? 'Victory!' + : 'Defeated'}

    {battleOutcome === null ? 'Checking battle outcome…' : isVictory - ? battleOutcome.leveledUp - ? 'Your pet won and leveled up!' - : 'Your pet won the battle!' - : 'Your pet was defeated. Train harder and try again!'} + ? battleOutcome.leveledUp + ? 'Your pet won and leveled up!' + : 'Your pet won the battle!' + : 'Your pet was defeated. Train harder and try again!'}

    {opponent && battleOutcome !== null ? (

    @@ -107,9 +113,13 @@ const BattleOverlay: React.FC = ({

    diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index 0d243733..ddd05266 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -71,7 +71,10 @@ const BattleSetup: React.FC = ({
    {!isStandaloneView && ( <> -

    Battle Pets

    +

    + + Battle Pets +

    {subtitle}

    )} @@ -103,7 +106,10 @@ const BattleSetup: React.FC = ({
    - Battle Arena + + + Battle Arena +
    - {isArenaFighting ? 'Fighting' : showResult ? 'Complete' : isArenaReady ? 'Ready' : 'Setup'} + {isArenaFighting + ? 'Fighting' + : showResult + ? 'Complete' + : isArenaReady + ? 'Ready' + : 'Setup'}
    - +
    - +
    VS
    @@ -142,9 +164,18 @@ const BattleSetup: React.FC = ({
    - - {opponent ? : null} - + + {opponent ? ( + + ) : null} + {isArenaReady && (
    @@ -153,11 +184,19 @@ const BattleSetup: React.FC = ({ ) : winEstimate.winProbability != null ? ( <> Win odds - = 0.5 ? ' favorable' : ' unfavorable'}`}> + = 0.5 + ? ' favorable' + : ' unfavorable' + }`} + > {Math.round(winEstimate.winProbability * 100)}% {winEstimate.samples != null && ( - ({winEstimate.samples.toLocaleString()} sim) + + ({winEstimate.samples.toLocaleString()} sim) + )} ) : ( @@ -197,7 +236,8 @@ const BattleSetup: React.FC = ({
    Finding challengers in the arena…
    ) : sortedOpponents.length === 0 ? (
    - No opponents available right now. Check back after more players join the roster. + No opponents available right now. Check back after more players join the + roster.
    ) : (
    @@ -210,7 +250,11 @@ const BattleSetup: React.FC = ({ fighterLevel={fighterLevel} selected={selectedOpponentKey === key} onSelect={onSelectOpponent} - cardRef={selectedOpponentKey === key ? selectedOpponentCardRef : undefined} + cardRef={ + selectedOpponentKey === key + ? selectedOpponentCardRef + : undefined + } /> ); })} diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx index 26585c70..29ee9405 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/fighter-picker-card.tsx @@ -9,7 +9,12 @@ type FighterPickerCardProps = { }; /** Selectable card for one of the player's own ready fighters. */ -const FighterPickerCard: React.FC = ({ pet, petId, selected, onSelect }) => ( +const FighterPickerCard: React.FC = ({ + pet, + petId, + selected, + onSelect, +}) => (
    - + {getRarityName(pet.rarity)} diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx index bbda9e02..23da7367 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/opponent-picker-card.tsx @@ -28,7 +28,9 @@ const OpponentPickerCard: React.FC = ({
    diff --git a/frontend/src/components/pet/interactions/panels/breed/index.tsx b/frontend/src/components/pet/interactions/panels/breed/index.tsx index 1ab74d9b..01b30517 100644 --- a/frontend/src/components/pet/interactions/panels/breed/index.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/index.tsx @@ -35,7 +35,10 @@ type BreedTab = 'own' | 'spouse'; const SPOUSE_NAME_GQL = `query($chain:String!,$id:String!){pet(chain:$chain,id:$id){name}}`; /** Fetches spouse pet name immediately (no debounce) — shows ID as fallback. */ -const SpouseLabel: React.FC<{ chain: PetChain | null; spouseId: string }> = ({ chain, spouseId }) => { +const SpouseLabel: React.FC<{ chain: PetChain | null; spouseId: string }> = ({ + chain, + spouseId, +}) => { const apiClient = useApiClient(); const baseURL = apiClient.defaults.baseURL ?? ''; const { data } = useQuery({ @@ -89,7 +92,7 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { // lazily via useMarriageInfo after selection). useEffect(() => { if (tab === 'spouse' && !spousePetId) { - const marriedPet = pets.find(p => p.spouseId != null && p.spouseId !== 0); + const marriedPet = pets.find((p) => p.spouseId != null && p.spouseId !== 0); if (marriedPet) { setSpousePetId(marriedPet.id); } else if (pets.length === 1) { @@ -116,14 +119,26 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { const petCoreAddress = evm?.petCore.address as `0x${string}` | undefined; const petCoreAbi = useMemo(() => evm?.petCore.abi ?? [], [evm?.petCore.abi]); const relPetA = tab === 'own' ? ownPet1 : spousePetId; - const relPetB = tab === 'own' ? ownPet2 : (spouseId ?? ''); + const relPetB = tab === 'own' ? ownPet2 : spouseId ?? ''; const relEnabled = isEvm && Boolean(petCoreAddress && relPetA && relPetB); const { data: breedInfoData } = useReadContracts({ contracts: relEnabled ? [ - { address: petCoreAddress!, abi: petCoreAbi, functionName: 'getBreedInfo' as const, args: [BigInt(relPetA)] as const, chainId: evm?.chainId }, - { address: petCoreAddress!, abi: petCoreAbi, functionName: 'getBreedInfo' as const, args: [BigInt(relPetB)] as const, chainId: evm?.chainId }, + { + address: petCoreAddress!, + abi: petCoreAbi, + functionName: 'getBreedInfo' as const, + args: [BigInt(relPetA)] as const, + chainId: evm?.chainId, + }, + { + address: petCoreAddress!, + abi: petCoreAbi, + functionName: 'getBreedInfo' as const, + args: [BigInt(relPetB)] as const, + chainId: evm?.chainId, + }, ] : [], allowFailure: true, @@ -164,8 +179,11 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { const handleSuccess = useCallback( ({ name }: { name: string }) => { setSuccess(`"${name}" created!`); - setOwnPet1(''); setOwnPet2(''); setOwnChildName(''); - setSpousePetId(''); setSpouseChildName(''); + setOwnPet1(''); + setOwnPet2(''); + setOwnChildName(''); + setSpousePetId(''); + setSpouseChildName(''); void refetch(); }, [refetch], @@ -182,8 +200,10 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { const buttonLabel = breed.isPending ? pendingLabel : breed.isAwaitingFulfillment - ? 'Creating…' - : tab === 'own' ? 'Breed Pets' : 'Breed with Spouse'; + ? 'Creating…' + : tab === 'own' + ? 'Breed Pets' + : 'Breed with Spouse'; const canSubmit = tab === 'own' @@ -196,9 +216,18 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { if (!canSubmit) return; if (tab === 'own') { - void breed.mutate({ parentId1: ownPet1, parentId2: ownPet2, name: ownChildName.trim() }); + void breed.mutate({ + parentId1: ownPet1, + parentId2: ownPet2, + name: ownChildName.trim(), + }); } else if (spouseId) { - void breed.mutate({ parentId1: spousePetId, parentId2: spouseId, name: spouseChildName.trim(), crossOwner: true }); + void breed.mutate({ + parentId1: spousePetId, + parentId2: spouseId, + name: spouseChildName.trim(), + crossOwner: true, + }); } }; @@ -206,7 +235,10 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { <>
    {!isStandaloneView && ( -

    Breed Pets

    +

    + + Breed Pets +

    )} {/* Tab bar */} @@ -233,15 +265,22 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { {pets.length < 2 ? (

    You need at least 2 pets to breed here.

    -

    Use the With Spouse tab if your pet is married.

    +

    + Use the With Spouse tab if your pet is married. +

    ) : ( <> -

    Select two of your pets to breed together.

    +

    + Select two of your pets to breed together. +

    - setOwnPet1(e.target.value)} + > {allPets.map(({ id, pet }) => (
    - setOwnPet2(e.target.value)} + > - {allPets.filter(({ id }) => id !== ownPet1).map(({ id, pet }) => ( - - ))} + {allPets + .filter(({ id }) => id !== ownPet1) + .map(({ id, pet }) => ( + + ))}
    @@ -269,8 +313,15 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { )} {!breed.isAwaitingFulfillment && ( <> - - + + )}
    @@ -291,15 +342,21 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { {/* ── With Spouse tab ──────────────────────────────────────────── */} {tab === 'spouse' && (
    -

    Select one of your pets to breed with their spouse.

    +

    + Select one of your pets to breed with their spouse. +

    - setSpousePetId(e.target.value)} + > {allPets.map(({ id, pet }) => ( ))} @@ -308,7 +365,9 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => {
    {!spousePetId ? ( - — select your pet first — + + — select your pet first — + ) : marriageInfo.isLoading ? ( Checking… ) : spouseId ? ( @@ -324,7 +383,9 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { {spousePetId && !marriageInfo.isLoading && !marriageInfo.isMarried && (

    This pet is not married yet.

    -

    Go to the Marriage page to propose first.

    +

    + Go to the Marriage page to propose first. +

    )} @@ -333,12 +394,14 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { <> {studFeeLabel && (
    - Stud fee: {studFeeLabel} — paid to the spouse owner. + Stud fee: {studFeeLabel} — paid to the + spouse owner.
    )} {areRelated && (

    - Your pet and their spouse are relatives and cannot breed together. + Your pet and their spouse are relatives and cannot breed + together.

    )} {/* Only show recovery notice for the user's own pet. @@ -346,7 +409,10 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { is in-flight, but the user can't settle/cancel it and showing those buttons there is confusing. */} {!breed.isAwaitingFulfillment && ( - + )}
    @@ -379,9 +445,7 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => {
    - {breed.isAwaitingFulfillment && ( -

    {AWAITING_HINT}

    - )} + {breed.isAwaitingFulfillment &&

    {AWAITING_HINT}

    }
    {success && ( @@ -391,9 +455,7 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => {
    )} - {hashHint && ( -

    Transaction: {hashHint}

    - )} + {hashHint &&

    Transaction: {hashHint}

    } diff --git a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx index da3ac933..0c4285c7 100644 --- a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx @@ -20,7 +20,11 @@ type PendingBreedNoticeProps = { * again. EVM: Settle once VRF has fulfilled, or Cancel beforehand. * Solana: recovery is automatic on the next breed attempt. */ -const PendingBreedNotice: React.FC = ({ petId, label, checkSolana = false }) => { +const PendingBreedNotice: React.FC = ({ + petId, + label, + checkSolana = false, +}) => { const pending = usePendingBreed(petId); const solanaPending = usePendingSolanaBreed(checkSolana); useTxErrorToast(pending.settle.error ?? pending.cancel.error ?? solanaPending.cancel.error); @@ -41,7 +45,11 @@ const PendingBreedNotice: React.FC = ({ petId, label, c

    {solanaPending.canCancel && (
    -
    @@ -54,9 +62,8 @@ const PendingBreedNotice: React.FC = ({ petId, label, c return (

    - {who} has an unresolved breed. Settle it once the - randomness is ready (mints the offspring), or cancel it if it - hasn't arrived yet. + {who} has an unresolved breed. Settle it once the randomness is + ready (mints the offspring), or cancel it if it hasn't arrived yet.

    ); diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx index 6eaa8120..cfe7cc84 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx @@ -11,10 +11,16 @@ type OutgoingProposalRowProps = { /** A single outgoing proposal row in the Propose tab. Renders nothing unless this * pet has a pending proposal owned by the connected wallet. */ -const OutgoingProposalRow: React.FC = ({ pet, walletAddress, onCancel, busy }) => { +const OutgoingProposalRow: React.FC = ({ + pet, + walletAddress, + onCancel, + busy, +}) => { const info = useMarriageInfo(pet); const isOwn = - info.hasProposal && walletAddress != null && + info.hasProposal && + walletAddress != null && info.proposer?.toLowerCase() === walletAddress.toLowerCase(); if (!isOwn) return null; @@ -23,7 +29,9 @@ const OutgoingProposalRow: React.FC = ({ pet, walletAd return (
  • - {pet.name} #{pet.id} + + {pet.name} #{pet.id} + #{info.proposalPetIdB?.toString()}
    diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx index 734e4aef..8caabdd0 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx @@ -38,14 +38,19 @@ const ProposeTab: React.FC = ({ return (
    -

    Select one of your pets, then search for your partner's pet to send a marriage proposal.

    +

    + Select one of your pets, then search for your partner's pet to send a marriage + proposal. +

    diff --git a/frontend/src/components/pet/interactions/panels/rename/index.tsx b/frontend/src/components/pet/interactions/panels/rename/index.tsx index 6bcb678f..854ec392 100644 --- a/frontend/src/components/pet/interactions/panels/rename/index.tsx +++ b/frontend/src/components/pet/interactions/panels/rename/index.tsx @@ -1,12 +1,7 @@ import React, { useMemo, useState } from 'react'; import TransactionStatus from '@components/common/transaction-status'; import { AuthActionButton } from '@components/common'; -import { - getReadyPetsUnified, - useChainCapabilities, - usePetList, - useRenamePet, -} from '@shared/core'; +import { getReadyPetsUnified, useChainCapabilities, usePetList, useRenamePet } from '@shared/core'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; import Icon, { CheckIcon, QuillIcon } from '@components/ui/icon'; @@ -33,7 +28,13 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) => refetch(); }; - const { mutate, isPending, error: hookError, reset, lifecycle } = useRenamePet({ + const { + mutate, + isPending, + error: hookError, + reset, + lifecycle, + } = useRenamePet({ onSuccess: handleRenameComplete, }); const readyPets = useMemo(() => getReadyPetsUnified(pets), [pets]); @@ -41,8 +42,11 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) => useTxErrorToast(hookError); const selectablePets = useMemo( - () => (renameMinLevel > 1 ? readyPets.filter(({ pet }) => pet.level >= renameMinLevel) : readyPets), - [readyPets, renameMinLevel] + () => + renameMinLevel > 1 + ? readyPets.filter(({ pet }) => pet.level >= renameMinLevel) + : readyPets, + [readyPets, renameMinLevel], ); const handleChangeName = async () => { @@ -66,7 +70,10 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) =>
    {!isStandaloneView && ( <> -

    Change Pet Name

    +

    + + Change Pet Name +

    {renameMinLevel > 1 ? `Change your pet's name (requires level ${renameMinLevel}+)` @@ -104,7 +111,10 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) =>

    - + {isPending ? 'Changing Name...' : 'Change Name'}
    diff --git a/frontend/src/components/pet/interactions/panels/train/index.tsx b/frontend/src/components/pet/interactions/panels/train/index.tsx index 03a4aa10..c54186b7 100644 --- a/frontend/src/components/pet/interactions/panels/train/index.tsx +++ b/frontend/src/components/pet/interactions/panels/train/index.tsx @@ -1,12 +1,7 @@ import React, { useMemo, useState } from 'react'; import TransactionStatus from '@components/common/transaction-status'; import { AuthActionButton } from '@components/common'; -import { - getReadyPetsUnified, - useFees, - useTrainPet, - usePetList, -} from '@shared/core'; +import { getReadyPetsUnified, useFees, useTrainPet, usePetList } from '@shared/core'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; import Icon, { CheckIcon } from '@components/ui/icon'; @@ -29,7 +24,13 @@ const TrainPanel: React.FC = ({ isStandaloneView = true }) => { refetch(); }; - const { mutate, isPending, error: hookError, reset, lifecycle } = useTrainPet({ + const { + mutate, + isPending, + error: hookError, + reset, + lifecycle, + } = useTrainPet({ onSuccess: handleTrainComplete, }); const readyPets = useMemo(() => getReadyPetsUnified(pets), [pets]); @@ -61,9 +62,7 @@ const TrainPanel: React.FC = ({ isStandaloneView = true }) => { } }; - const buttonLabel = isPending - ? 'Training...' - : trainCost ? `Train (${trainCost})` : 'Train'; + const buttonLabel = isPending ? 'Training...' : trainCost ? `Train (${trainCost})` : 'Train'; return ( <> diff --git a/frontend/src/components/pet/interactions/standalone/index.tsx b/frontend/src/components/pet/interactions/standalone/index.tsx index ca8eb054..346cfe79 100644 --- a/frontend/src/components/pet/interactions/standalone/index.tsx +++ b/frontend/src/components/pet/interactions/standalone/index.tsx @@ -15,7 +15,11 @@ export type InteractionStandaloneProps = { children: React.ReactNode; }; -const InteractionStandalone: React.FC = ({ action, minPets, children }) => { +const InteractionStandalone: React.FC = ({ + action, + minPets, + children, +}) => { const navigate = useNavigate(); const { isConnected } = useChainCapabilities(); const { pets, isLoading } = usePetList(); @@ -26,7 +30,12 @@ const InteractionStandalone: React.FC = ({ action, m return ( Pet Interactions} + title={ + <> + + Pet Interactions + + } description="Connect your wallet to interact with your pets" back={goBack} /> @@ -37,7 +46,12 @@ const InteractionStandalone: React.FC = ({ action, m return ( {header.label}} + title={ + <> + + {header.label} + + } back={goBack} >
    @@ -52,7 +66,12 @@ const InteractionStandalone: React.FC = ({ action, m return ( {header.label}} + title={ + <> + + {header.label} + + } description="You don't have any pets yet." helpText="Go to the dashboard and create your first pet." back={goBack} @@ -64,7 +83,12 @@ const InteractionStandalone: React.FC = ({ action, m return ( {header.label}} + title={ + <> + + {header.label} + + } sub={header.sub} description="You need at least two pets to breed or battle." helpText="Create another pet from the dashboard, then come back here." @@ -76,7 +100,12 @@ const InteractionStandalone: React.FC = ({ action, m return ( {header.label}} + title={ + <> + + {header.label} + + } sub={header.sub} back={goBack} > diff --git a/frontend/src/components/pet/transfer/send-pet-modal/index.css b/frontend/src/components/pet/transfer/send-pet-modal/index.css index ba474742..104cc48c 100644 --- a/frontend/src/components/pet/transfer/send-pet-modal/index.css +++ b/frontend/src/components/pet/transfer/send-pet-modal/index.css @@ -20,9 +20,7 @@ background: linear-gradient(180deg, rgb(10 16 32 / 96%), rgb(6 10 22 / 98%)); border: 1px solid var(--neon-border-soft); border-radius: 16px; - box-shadow: - inset 0 0 24px rgb(125 214 255 / 8%), - 0 0 24px rgb(181 140 255 / 22%), + box-shadow: inset 0 0 24px rgb(125 214 255 / 8%), 0 0 24px rgb(181 140 255 / 22%), 0 20px 56px rgb(0 0 0 / 50%); max-height: var(--modal-max-height); overflow: hidden; @@ -226,17 +224,13 @@ background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); color: var(--neon-cyan); border: 1px solid rgb(125 214 255 / 50%); - box-shadow: - inset 0 0 16px rgb(125 214 255 / 18%), - 0 0 16px rgb(125 214 255 / 24%); + box-shadow: inset 0 0 16px rgb(125 214 255 / 18%), 0 0 16px rgb(125 214 255 / 24%); &:hover:not(:disabled) { transform: translateY(-2px); color: var(--neon-violet); border-color: rgb(181 140 255 / 60%); - box-shadow: - inset 0 0 22px rgb(181 140 255 / 22%), - 0 0 28px rgb(181 140 255 / 38%); + box-shadow: inset 0 0 22px rgb(181 140 255 / 22%), 0 0 28px rgb(181 140 255 / 38%); } @media (max-width: 768px) { diff --git a/frontend/src/components/pet/transfer/send-pet-modal/index.tsx b/frontend/src/components/pet/transfer/send-pet-modal/index.tsx index 8a7d6d42..9b87671c 100644 --- a/frontend/src/components/pet/transfer/send-pet-modal/index.tsx +++ b/frontend/src/components/pet/transfer/send-pet-modal/index.tsx @@ -1,9 +1,5 @@ import React, { useState } from 'react'; -import { - useChainCapabilities, - usePetList, - useTransferPet, -} from '@shared/core'; +import { useChainCapabilities, usePetList, useTransferPet } from '@shared/core'; import TransactionStatus from '@components/common/transaction-status'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; @@ -21,12 +17,7 @@ interface SendPetModalProps { petId: bigint; } -const SendPetModal: React.FC = ({ - isOpen, - onClose, - pet, - petId, -}) => { +const SendPetModal: React.FC = ({ isOpen, onClose, pet, petId }) => { const { address: addrCaps, chainLabel, walletAddress } = useChainCapabilities(); const { refetch } = usePetList(); const notifyError = useNotifyError(); @@ -42,7 +33,13 @@ const SendPetModal: React.FC = ({ onClose(); }; - const { mutate, isPending, error: hookError, reset, lifecycle } = useTransferPet({ + const { + mutate, + isPending, + error: hookError, + reset, + lifecycle, + } = useTransferPet({ onSuccess: handleTransferComplete, }); @@ -94,11 +91,7 @@ const SendPetModal: React.FC = ({
    e.stopPropagation()}>

    Send Pet

    -
    @@ -107,9 +100,15 @@ const SendPetModal: React.FC = ({

    {pet.name}

    -

    Level: {pet.level}

    -

    DNA: {pet.dna.toString()}

    -

    Rarity: {pet.rarity}

    +

    + Level: {pet.level} +

    +

    + DNA: {pet.dna.toString()} +

    +

    + Rarity: {pet.rarity} +

    @@ -130,11 +129,7 @@ const SendPetModal: React.FC = ({
    - - ); + return ( + + ); }; export default NeonButton; diff --git a/frontend/src/components/ui/neon-card/index.css b/frontend/src/components/ui/neon-card/index.css index dedc27fa..bd5c01f5 100644 --- a/frontend/src/components/ui/neon-card/index.css +++ b/frontend/src/components/ui/neon-card/index.css @@ -1,61 +1,52 @@ .neon-card { - position: relative; - border-radius: var(--border-radius, 12px); - overflow: hidden; - transition: - transform 180ms ease, - box-shadow 180ms ease, - border-color 180ms ease, - filter 180ms ease; - - &::after { - content: ''; - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - opacity: 0; - box-shadow: - inset 0 0 0 1px rgb(126 170 255 / 24%), - inset 0 0 22px rgb(86 136 255 / 12%); - transition: opacity 180ms ease, box-shadow 180ms ease; - } - - &:hover::after { - opacity: 1; - box-shadow: - inset 0 0 0 1px rgb(148 191 255 / 42%), - inset 0 0 30px rgb(100 152 255 / 24%); - } - - &:hover { - transform: translateY(-2px); - filter: brightness(1.03); - } - - &:active { - transform: translateY(0) scale(0.985); - filter: brightness(0.96); - } - - &:active::after { - opacity: 1; - box-shadow: - inset 0 0 0 1px rgb(120 170 255 / 36%), - inset 0 0 18px rgb(84 136 255 / 16%); - } - - @media (prefers-reduced-motion: reduce) { - transition: none; + position: relative; + border-radius: var(--border-radius, 12px); + overflow: hidden; + transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease, + filter 180ms ease; &::after { - transition: none; + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + opacity: 0; + box-shadow: inset 0 0 0 1px rgb(126 170 255 / 24%), inset 0 0 22px rgb(86 136 255 / 12%); + transition: opacity 180ms ease, box-shadow 180ms ease; + } + + &:hover::after { + opacity: 1; + box-shadow: inset 0 0 0 1px rgb(148 191 255 / 42%), inset 0 0 30px rgb(100 152 255 / 24%); + } + + &:hover { + transform: translateY(-2px); + filter: brightness(1.03); } - &:hover, &:active { - transform: none; - filter: none; + transform: translateY(0) scale(0.985); + filter: brightness(0.96); + } + + &:active::after { + opacity: 1; + box-shadow: inset 0 0 0 1px rgb(120 170 255 / 36%), inset 0 0 18px rgb(84 136 255 / 16%); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + + &::after { + transition: none; + } + + &:hover, + &:active { + transform: none; + filter: none; + } } - } } diff --git a/frontend/src/components/ui/neon-card/index.tsx b/frontend/src/components/ui/neon-card/index.tsx index 37ef96e4..0b012ca3 100644 --- a/frontend/src/components/ui/neon-card/index.tsx +++ b/frontend/src/components/ui/neon-card/index.tsx @@ -4,23 +4,18 @@ import clsx from 'clsx'; import './index.css'; type NeonCardProps = React.HTMLAttributes & { - as?: 'article' | 'div' | 'section'; + as?: 'article' | 'div' | 'section'; }; -const NeonCard = ({ - as = 'article', - className, - children, - ...props -}: NeonCardProps) => { - const Tag = as; - const classes = clsx('neon-card', className); +const NeonCard = ({ as = 'article', className, children, ...props }: NeonCardProps) => { + const Tag = as; + const classes = clsx('neon-card', className); - return ( - - {children} - - ); + return ( + + {children} + + ); }; export default NeonCard; diff --git a/frontend/src/components/ui/neon-modal/index.css b/frontend/src/components/ui/neon-modal/index.css index 6eb46399..c68b146f 100644 --- a/frontend/src/components/ui/neon-modal/index.css +++ b/frontend/src/components/ui/neon-modal/index.css @@ -1,69 +1,67 @@ .neon-modal { - position: fixed; - inset: 0; - display: flex; - justify-content: center; - align-items: center; - padding: 20px; - z-index: 1000; - background: rgb(3 8 18 / 72%); - backdrop-filter: blur(2px); + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + z-index: 1000; + background: rgb(3 8 18 / 72%); + backdrop-filter: blur(2px); - .dialog { - width: min(100%, 420px); - max-height: 80vh; - overflow: hidden; - border: 1px solid rgb(30 157 255 / 45%); - border-radius: 8px; - background: linear-gradient(180deg, rgb(5 13 30 / 96%), rgb(4 8 22 / 98%)); - color: #b7e4ff; - box-shadow: - inset 0 0 16px rgb(30 157 255 / 14%), - 0 0 20px rgb(30 157 255 / 22%), - 0 16px 40px rgb(0 0 0 / 38%); + .dialog { + width: min(100%, 420px); + max-height: 80vh; + overflow: hidden; + border: 1px solid rgb(30 157 255 / 45%); + border-radius: 8px; + background: linear-gradient(180deg, rgb(5 13 30 / 96%), rgb(4 8 22 / 98%)); + color: #b7e4ff; + box-shadow: inset 0 0 16px rgb(30 157 255 / 14%), 0 0 20px rgb(30 157 255 / 22%), + 0 16px 40px rgb(0 0 0 / 38%); - .header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 16px 18px; - border-bottom: 1px solid rgb(30 157 255 / 30%); - background: linear-gradient(180deg, rgb(10 24 52 / 56%), rgb(8 18 40 / 35%)); - } + .header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid rgb(30 157 255 / 30%); + background: linear-gradient(180deg, rgb(10 24 52 / 56%), rgb(8 18 40 / 35%)); + } - .title { - margin: 0; - font-size: 1.05rem; - letter-spacing: 0.35px; - text-transform: uppercase; - color: #8ed1ff; - } + .title { + margin: 0; + font-size: 1.05rem; + letter-spacing: 0.35px; + text-transform: uppercase; + color: #8ed1ff; + } - .controls { - display: flex; - align-items: center; - gap: 10px; - } + .controls { + display: flex; + align-items: center; + gap: 10px; + } - .close-btn.neon-btn { - min-width: 72px; - justify-content: center; - } + .close-btn.neon-btn { + min-width: 72px; + justify-content: center; + } - .body { - padding: 14px; - max-height: calc(80vh - 68px); - overflow-y: auto; + .body { + padding: 14px; + max-height: calc(80vh - 68px); + overflow-y: auto; + } } - } - @media (max-width: 480px) { - padding: 12px; + @media (max-width: 480px) { + padding: 12px; - .dialog { - width: 100%; - max-height: 86vh; + .dialog { + width: 100%; + max-height: 86vh; + } } - } } diff --git a/frontend/src/components/ui/neon-modal/index.tsx b/frontend/src/components/ui/neon-modal/index.tsx index 70481c24..7dc2d684 100644 --- a/frontend/src/components/ui/neon-modal/index.tsx +++ b/frontend/src/components/ui/neon-modal/index.tsx @@ -7,58 +7,58 @@ import NeonButton from '@components/ui/neon-button'; import './index.css'; type NeonModalProps = { - isOpen: boolean; - onRequestClose: () => void; - title: ReactNode; - children: ReactNode; - headerActions?: ReactNode; - className?: string; - contentClassName?: string; + isOpen: boolean; + onRequestClose: () => void; + title: ReactNode; + children: ReactNode; + headerActions?: ReactNode; + className?: string; + contentClassName?: string; }; const NeonModal = ({ - isOpen, - onRequestClose, - title, - children, - headerActions, - className, - contentClassName, + isOpen, + onRequestClose, + title, + children, + headerActions, + className, + contentClassName, }: NeonModalProps) => { - useEffect(() => { - Modal.setAppElement('#root'); - }, []); + useEffect(() => { + Modal.setAppElement('#root'); + }, []); - const dialogClassName = ['dialog', className].filter(Boolean).join(' '); - const bodyClassName = ['body', contentClassName].filter(Boolean).join(' '); + const dialogClassName = ['dialog', className].filter(Boolean).join(' '); + const bodyClassName = ['body', contentClassName].filter(Boolean).join(' '); - return ( - -
    -

    {title}

    -
    - {headerActions} - - Close - -
    -
    -
    {children}
    -
    - ); + return ( + +
    +

    {title}

    +
    + {headerActions} + + Close + +
    +
    +
    {children}
    +
    + ); }; export default NeonModal; diff --git a/frontend/src/components/ui/pet-search-dropdown/index.css b/frontend/src/components/ui/pet-search-dropdown/index.css index 373d2b14..82d179c0 100644 --- a/frontend/src/components/ui/pet-search-dropdown/index.css +++ b/frontend/src/components/ui/pet-search-dropdown/index.css @@ -20,17 +20,13 @@ border-radius: 4px; background: rgb(4 10 26 / 96%); transition: border-color 0.2s ease, box-shadow 0.2s ease; - box-shadow: - inset 0 0 20px rgb(125 214 255 / 4%), - inset 0 1px 0 rgb(125 214 255 / 8%); + box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), inset 0 1px 0 rgb(125 214 255 / 8%); min-height: 42px; cursor: text; &:hover:not(.is-selected) { border-color: rgb(125 214 255 / 55%); - box-shadow: - inset 0 0 20px rgb(125 214 255 / 7%), - 0 0 10px rgb(125 214 255 / 14%); + box-shadow: inset 0 0 20px rgb(125 214 255 / 7%), 0 0 10px rgb(125 214 255 / 14%); } &.is-open { @@ -40,9 +36,7 @@ &.is-selected { border-color: rgb(125 214 255 / 55%); - box-shadow: - inset 0 0 20px rgb(125 214 255 / 7%), - 0 0 8px rgb(125 214 255 / 16%); + box-shadow: inset 0 0 20px rgb(125 214 255 / 7%), 0 0 8px rgb(125 214 255 / 16%); } } @@ -124,9 +118,7 @@ background: rgb(4 10 26 / 98%); border: 1px solid rgb(125 214 255 / 30%); border-radius: 4px; - box-shadow: - 0 8px 32px rgb(0 0 0 / 55%), - 0 0 0 1px rgb(125 214 255 / 10%), + box-shadow: 0 8px 32px rgb(0 0 0 / 55%), 0 0 0 1px rgb(125 214 255 / 10%), inset 0 1px 0 rgb(125 214 255 / 8%); overflow: hidden; max-height: 240px; diff --git a/frontend/src/components/ui/pet-search-dropdown/index.tsx b/frontend/src/components/ui/pet-search-dropdown/index.tsx index cffacae3..b5730e39 100644 --- a/frontend/src/components/ui/pet-search-dropdown/index.tsx +++ b/frontend/src/components/ui/pet-search-dropdown/index.tsx @@ -150,40 +150,48 @@ const PetSearchDropdown: React.FC = ({ const isSelected = selected !== null; - const dropdownPortal = open && dropdownStyle - ? createPortal( -
    - {isLoading &&
    Searching…
    } - {!isLoading && filtered.length === 0 && inputText.trim() && ( -
    No pets found
    - )} - {filtered.map((pet, i) => ( - - ))} -
    , - document.body, - ) - : null; + const dropdownPortal = + open && dropdownStyle + ? createPortal( +
    + {isLoading &&
    Searching…
    } + {!isLoading && filtered.length === 0 && inputText.trim() && ( +
    No pets found
    + )} + {filtered.map((pet, i) => ( + + ))} +
    , + document.body, + ) + : null; return (
    -
    +
    = ({ spellCheck={false} /> {isSelected && ( - #{selected.id} · Lv {selected.level} + + #{selected.id} · Lv {selected.level} + )} {isSelected ? ( - ) : ( - + + ▾ + )}
    diff --git a/frontend/src/components/ui/toast/index.css b/frontend/src/components/ui/toast/index.css index 90477e66..962467b6 100644 --- a/frontend/src/components/ui/toast/index.css +++ b/frontend/src/components/ui/toast/index.css @@ -20,16 +20,12 @@ border-radius: 10px; background: linear-gradient(180deg, rgb(10 16 32 / 96%), rgb(6 10 22 / 98%)); border: 1px solid rgb(125 214 255 / 28%); - box-shadow: - 0 10px 28px rgb(0 0 0 / 42%), - inset 0 0 16px rgb(125 214 255 / 6%); + box-shadow: 0 10px 28px rgb(0 0 0 / 42%), inset 0 0 16px rgb(125 214 255 / 6%); animation: toast-enter 0.32s cubic-bezier(0.22, 1, 0.36, 1); &.toast-error { border-color: rgb(255 110 196 / 42%); - box-shadow: - 0 10px 28px rgb(0 0 0 / 42%), - inset 0 0 16px rgb(255 110 196 / 10%); + box-shadow: 0 10px 28px rgb(0 0 0 / 42%), inset 0 0 16px rgb(255 110 196 / 10%); } &.toast-info { @@ -38,9 +34,7 @@ &.toast-success { border-color: rgb(0 255 157 / 42%); - box-shadow: - 0 10px 28px rgb(0 0 0 / 42%), - inset 0 0 16px rgb(0 255 157 / 10%); + box-shadow: 0 10px 28px rgb(0 0 0 / 42%), inset 0 0 16px rgb(0 255 157 / 10%); } .icon { diff --git a/frontend/src/components/ui/toast/index.tsx b/frontend/src/components/ui/toast/index.tsx index 09bcd983..326933ba 100644 --- a/frontend/src/components/ui/toast/index.tsx +++ b/frontend/src/components/ui/toast/index.tsx @@ -29,13 +29,13 @@ const toneIcon = (tone: ToastTone) => { if (tone === 'success') return CheckIcon; if (tone === 'info') return PauseIcon; return tone === 'error' ? CloseIcon : WarningIcon; -} +}; -const toneColor = (tone: ToastTone): Exclude => { +const toneColor = (tone: ToastTone): Exclude => { if (tone === 'success') return Tones.Emerald; if (tone === 'info') return Tones.Inherit; return Tones.Magenta; -} +}; export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [toasts, setToasts] = useState([]); @@ -95,10 +95,10 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre ); }; -export const useToast = (): ToastContextValue => { +export const useToast = (): ToastContextValue => { const context = useContext(ToastContext); if (!context) { throw new Error('useToast must be used within ToastProvider'); } return context; -} +}; diff --git a/frontend/src/components/wallet/account-dropdown/index.css b/frontend/src/components/wallet/account-dropdown/index.css index 291b7234..1fef6f99 100644 --- a/frontend/src/components/wallet/account-dropdown/index.css +++ b/frontend/src/components/wallet/account-dropdown/index.css @@ -36,9 +36,7 @@ background: linear-gradient(180deg, rgb(5 13 30 / 96%), rgb(4 8 22 / 98%)); border: 1px solid rgb(30 157 255 / 45%); border-radius: 12px; - box-shadow: - inset 0 0 16px rgb(30 157 255 / 14%), - 0 0 20px rgb(30 157 255 / 22%), + box-shadow: inset 0 0 16px rgb(30 157 255 / 14%), 0 0 20px rgb(30 157 255 / 22%), 0 16px 40px rgb(0 0 0 / 38%); min-width: 280px; overflow: hidden; diff --git a/frontend/src/components/wallet/account-dropdown/index.tsx b/frontend/src/components/wallet/account-dropdown/index.tsx index 0d08c72e..5547154e 100644 --- a/frontend/src/components/wallet/account-dropdown/index.tsx +++ b/frontend/src/components/wallet/account-dropdown/index.tsx @@ -7,7 +7,10 @@ import { getPopularTokens } from '@constants/tokens'; import { Tones } from '@constants/tones'; import { NeonButton, NeonCard } from '@components/ui'; import Icon, { CheckIcon, CopyIcon } from '@components/ui/icon'; -import { EthereumNetworkSwitcher, SolanaNetworkSwitcher } from '@components/wallet/network-switcher'; +import { + EthereumNetworkSwitcher, + SolanaNetworkSwitcher, +} from '@components/wallet/network-switcher'; import TokenBalance from '@components/wallet/token-balance'; import NativeBalance from '@components/wallet/native-balance'; import './index.css'; @@ -38,23 +41,23 @@ const CopyableAddress: React.FC = ({ address, isCopied, on const AccountDropdown: React.FC = () => { const { address, isConnected, chain } = useAccount(); - const { publicKey: solanaPublicKey, connected: solanaConnected, disconnect: solanaDisconnect } = useWallet(); + const { + publicKey: solanaPublicKey, + connected: solanaConnected, + disconnect: solanaDisconnect, + } = useWallet(); const { setShowAuthFlow, handleLogOut, user, primaryWallet } = useDynamicContext(); const [isOpen, setIsOpen] = useState(false); const [copiedAddress, setCopiedAddress] = useState(null); - const [tokenStatus, setTokenStatus] = useState>({}); + const [tokenStatus, setTokenStatus] = useState< + Record + >({}); const [isTokensLoading, setIsTokensLoading] = useState(false); const dropdownRef = useRef(null); - const { - isAuthenticated, - logout, - signAndLogin, - isSigning, - isVerifying, - isNonceLoading - } = useAuth(); + const { isAuthenticated, logout, signAndLogin, isSigning, isVerifying, isNonceLoading } = + useAuth(); const popularTokens = useMemo(() => getPopularTokens(chain?.id), [chain?.id]); const publicClient = usePublicClient(); @@ -81,41 +84,52 @@ const AccountDropdown: React.FC = () => { setIsTokensLoading(true); try { - const calls = popularTokens.map(token => ({ + const calls = popularTokens.map((token) => ({ address: token.address as `0x${string}`, - abi: [{ - type: 'function', - name: 'balanceOf', - stateMutability: 'view', - inputs: [{ name: 'owner', type: 'address' }], - outputs: [{ name: '', type: 'uint256' }] - }], + abi: [ + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + ], functionName: 'balanceOf', - args: [address as `0x${string}`] + args: [address as `0x${string}`], })); - let results: Array<{ address: string; balance?: bigint | number; error?: unknown }> = []; + let results: Array<{ + address: string; + balance?: bigint | number; + error?: unknown; + }> = []; if ((publicClient as { multicall?: unknown })?.multicall) { - const multicallRes = await (publicClient as { multicall: (params: unknown) => Promise }).multicall({ + const multicallRes = await ( + publicClient as { multicall: (params: unknown) => Promise } + ).multicall({ contracts: calls, - allowFailure: true + allowFailure: true, }); - results = (multicallRes as Array<{ status: string; result?: unknown; error?: unknown }>).map((r, idx: number) => ({ + results = ( + multicallRes as Array<{ status: string; result?: unknown; error?: unknown }> + ).map((r, idx: number) => ({ address: calls[idx].address, balance: r.status === 'success' ? (r.result as bigint) : undefined, - error: r.status === 'failure' ? r.error : undefined + error: r.status === 'failure' ? r.error : undefined, })); } else { throw new Error('Multicall not available'); } - const newStatus: Record = {}; + const newStatus: Record = + {}; for (const r of results) { newStatus[r.address.toLowerCase()] = { fetched: true, - balance: r.balance !== undefined ? r.balance : 0n + balance: r.balance !== undefined ? r.balance : 0n, }; } @@ -133,15 +147,18 @@ const AccountDropdown: React.FC = () => { } }, [isOpen, address, publicClient, popularTokens]); - const fetchedCount = Object.values(tokenStatus).filter(s => s.fetched).length; - const withBalanceCount = Object.values(tokenStatus).filter(s => { + const fetchedCount = Object.values(tokenStatus).filter((s) => s.fetched).length; + const withBalanceCount = Object.values(tokenStatus).filter((s) => { if (!s.balance) return false; return typeof s.balance === 'bigint' ? s.balance > 0n : Number(s.balance) > 0; }).length; useEffect(() => { const handleClickOutside = (event: globalThis.MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as globalThis.Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as globalThis.Node) + ) { setIsOpen(false); } }; @@ -188,7 +205,11 @@ const AccountDropdown: React.FC = () => { if (!hasAnyWallet) { return (
    - setShowAuthFlow(true)}> + setShowAuthFlow(true)} + > Connect Wallet
    @@ -206,8 +227,7 @@ const AccountDropdown: React.FC = () => { tone={Tones.Azure} size="sm" > - {headerTriggerLabel}{' '} - {isOpen ? '▲' : '▼'} + {headerTriggerLabel} {isOpen ? '▲' : '▼'} {isOpen && ( @@ -225,7 +245,9 @@ const AccountDropdown: React.FC = () => { void handleCopyAny(solanaPublicKey.toString())} + onCopy={() => + void handleCopyAny(solanaPublicKey.toString()) + } /> )} {dynamicWalletAddress && @@ -265,15 +287,16 @@ const AccountDropdown: React.FC = () => { symbol={token.symbol} decimals={token.decimals} name={token.name} - balance={tokenStatus[token.address.toLowerCase()]?.balance} + balance={ + tokenStatus[token.address.toLowerCase()] + ?.balance + } /> ))} {!isTokensLoading && fetchedCount === popularTokens.length && withBalanceCount === 0 && ( -
    - No ERC-20 tokens -
    +
    No ERC-20 tokens
    )}
    @@ -289,9 +312,13 @@ const AccountDropdown: React.FC = () => { size="sm" fullWidth > - {isNonceLoading ? 'Getting nonce...' : - isSigning ? 'Please approve the signature in your wallet...' : - isVerifying ? 'Verifying...' : 'Sign Message & Login'} + {isNonceLoading + ? 'Getting nonce...' + : isSigning + ? 'Please approve the signature in your wallet...' + : isVerifying + ? 'Verifying...' + : 'Sign Message & Login'} ) : ( = ({ type, className }) => { const { connection } = useConnection(); // Ethereum balance - const { data: ethereumBalance, isLoading: isEthereumLoading, error: ethereumError } = useBalance({ + const { + data: ethereumBalance, + isLoading: isEthereumLoading, + error: ethereumError, + } = useBalance({ address, }); @@ -86,7 +90,10 @@ const NativeBalance: React.FC = ({ type, className }) => { return (
    - Error loading balance + + + Error loading balance +
    ); @@ -122,9 +129,7 @@ const NativeBalance: React.FC = ({ type, className }) => { return (
    - - {parseFloat(formattedBalance).toFixed(4)} - + {parseFloat(formattedBalance).toFixed(4)} {symbol}
    diff --git a/frontend/src/components/wallet/network-switcher/ethereum.tsx b/frontend/src/components/wallet/network-switcher/ethereum.tsx index cac6e734..4091a327 100644 --- a/frontend/src/components/wallet/network-switcher/ethereum.tsx +++ b/frontend/src/components/wallet/network-switcher/ethereum.tsx @@ -1,6 +1,11 @@ import React, { useState } from 'react'; import { useAccount, useSwitchChain } from 'wagmi'; -import { CHAINS, getChainConfig, getMainnetChains, getTestnetChains } from '@constants/chains/ethereum'; +import { + CHAINS, + getChainConfig, + getMainnetChains, + getTestnetChains, +} from '@constants/chains/ethereum'; import { Tones } from '@constants/tones'; import { NeonButton, NeonModal } from '@components/ui'; import Icon, { CheckIcon } from '@components/ui/icon'; @@ -16,7 +21,7 @@ const EthereumNetworkSwitcher: React.FC = ({ class const [isOpen, setIsOpen] = useState(false); const [showTestnets, setShowTestnets] = useState(() => { if (!chain) return false; - return CHAINS.some(c => c.chain.id === chain.id && c.isTestnet); + return CHAINS.some((c) => c.chain.id === chain.id && c.isTestnet); }); if (!chain) return null; @@ -31,9 +36,7 @@ const EthereumNetworkSwitcher: React.FC = ({ class return (
    - {switchError && ( -
    Error: {switchError.message}
    - )} + {switchError &&
    Error: {switchError.message}
    } = ({ class tone={Tones.Azure} size="sm" > - {isPending ? 'Switching...' : (currentChainConfig?.name || 'Unknown')} ▼ + {isPending ? 'Switching...' : currentChainConfig?.name || 'Unknown'} ▼ = ({ class title="Select Network" className="network-neon-modal" contentClassName="network-neon-modal-content" - headerActions={( + headerActions={ - )} + } >
    {visibleChains.map(({ chain: chainConfig, name, symbol, isTestnet }) => ( handleNetworkSelect(chainConfig.id)} disabled={isPending} tone={Tones.Azure} @@ -80,7 +85,12 @@ const EthereumNetworkSwitcher: React.FC = ({ class {chain.id === chainConfig.id && ( - + )} diff --git a/frontend/src/components/wallet/network-switcher/index.css b/frontend/src/components/wallet/network-switcher/index.css index ca31462e..a54e96dc 100644 --- a/frontend/src/components/wallet/network-switcher/index.css +++ b/frontend/src/components/wallet/network-switcher/index.css @@ -28,18 +28,14 @@ font-weight: 600; cursor: pointer; transition: all 0.2s ease; - box-shadow: - inset 0 0 12px rgb(125 214 255 / 12%), - 0 0 10px rgb(125 214 255 / 18%); + box-shadow: inset 0 0 12px rgb(125 214 255 / 12%), 0 0 10px rgb(125 214 255 / 18%); min-width: 120px; letter-spacing: 0.4px; &:hover:not(:disabled) { border-color: rgb(125 214 255 / 60%); transform: translateY(-1px); - box-shadow: - inset 0 0 16px rgb(125 214 255 / 18%), - 0 0 16px rgb(125 214 255 / 28%); + box-shadow: inset 0 0 16px rgb(125 214 255 / 18%), 0 0 16px rgb(125 214 255 / 28%); } &:hover .arrow { @@ -109,7 +105,6 @@ } @keyframes error-shake { - 0%, 100% { transform: translateX(0); @@ -149,7 +144,7 @@ color: #8ed1ff; cursor: pointer; - & input[type="checkbox"] { + & input[type='checkbox'] { margin: 0; cursor: pointer; accent-color: #1e9dff; @@ -184,9 +179,7 @@ } &.active { - box-shadow: - inset 0 0 18px rgb(0 255 157 / 18%), - 0 0 18px rgb(0 255 157 / 26%); + box-shadow: inset 0 0 18px rgb(0 255 157 / 18%), 0 0 18px rgb(0 255 157 / 26%); } &.testnet { diff --git a/frontend/src/components/wallet/network-switcher/solana.tsx b/frontend/src/components/wallet/network-switcher/solana.tsx index 24d8e58b..5f97c8c1 100644 --- a/frontend/src/components/wallet/network-switcher/solana.tsx +++ b/frontend/src/components/wallet/network-switcher/solana.tsx @@ -17,7 +17,7 @@ const SolanaNetworkSwitcher: React.FC = ({ className if (!connected) return null; - const currentNetworkConfig = SOLANA_NETWORKS.find(n => n.name === currentNetwork); + const currentNetworkConfig = SOLANA_NETWORKS.find((n) => n.name === currentNetwork); const handleNetworkSelect = (networkName: string) => { setCurrentNetwork(networkName); @@ -26,14 +26,9 @@ const SolanaNetworkSwitcher: React.FC = ({ className return (
    - @@ -51,7 +46,9 @@ const SolanaNetworkSwitcher: React.FC = ({ className return ( diff --git a/frontend/src/constants/chains/ethereum.ts b/frontend/src/constants/chains/ethereum.ts index a8d86df5..98a7da86 100644 --- a/frontend/src/constants/chains/ethereum.ts +++ b/frontend/src/constants/chains/ethereum.ts @@ -27,26 +27,26 @@ export interface ChainConfig { export const CHAINS: ChainConfig[] = [ // Local dev - { chain: hardhatLocal, name: 'Hardhat Local', symbol: 'ETH', isTestnet: true }, + { chain: hardhatLocal, name: 'Hardhat Local', symbol: 'ETH', isTestnet: true }, // Arbitrum - { chain: arbitrum, name: 'Arbitrum', symbol: 'ETH', isTestnet: false }, - { chain: arbitrumSepolia, name: 'Arbitrum Sepolia', symbol: 'ETH', isTestnet: true }, + { chain: arbitrum, name: 'Arbitrum', symbol: 'ETH', isTestnet: false }, + { chain: arbitrumSepolia, name: 'Arbitrum Sepolia', symbol: 'ETH', isTestnet: true }, // Optimism - { chain: optimism, name: 'Optimism', symbol: 'ETH', isTestnet: false }, - { chain: optimismSepolia, name: 'Optimism Sepolia', symbol: 'ETH', isTestnet: true }, + { chain: optimism, name: 'Optimism', symbol: 'ETH', isTestnet: false }, + { chain: optimismSepolia, name: 'Optimism Sepolia', symbol: 'ETH', isTestnet: true }, // Base - { chain: base, name: 'Base', symbol: 'ETH', isTestnet: false }, - { chain: baseSepolia, name: 'Base Sepolia', symbol: 'ETH', isTestnet: true }, + { chain: base, name: 'Base', symbol: 'ETH', isTestnet: false }, + { chain: baseSepolia, name: 'Base Sepolia', symbol: 'ETH', isTestnet: true }, ]; export const CHAIN_SYMBOLS: { [key: number]: string } = { - 31337: 'ETH', // Hardhat Local - 42161: 'ETH', // Arbitrum - 421614: 'ETH', // Arbitrum Sepolia - 10: 'ETH', // Optimism + 31337: 'ETH', // Hardhat Local + 42161: 'ETH', // Arbitrum + 421614: 'ETH', // Arbitrum Sepolia + 10: 'ETH', // Optimism 11155420: 'ETH', // Optimism Sepolia - 8453: 'ETH', // Base - 84532: 'ETH', // Base Sepolia + 8453: 'ETH', // Base + 84532: 'ETH', // Base Sepolia }; export const getNativeTokenSymbol = (chainId?: number): string => { @@ -55,13 +55,11 @@ export const getNativeTokenSymbol = (chainId?: number): string => { }; export const getChainConfig = (chainId: number): ChainConfig | undefined => - CHAINS.find(c => c.chain.id === chainId); + CHAINS.find((c) => c.chain.id === chainId); -export const getMainnetChains = (): ChainConfig[] => - CHAINS.filter(c => !c.isTestnet); +export const getMainnetChains = (): ChainConfig[] => CHAINS.filter((c) => !c.isTestnet); -export const getTestnetChains = (): ChainConfig[] => - CHAINS.filter(c => c.isTestnet); +export const getTestnetChains = (): ChainConfig[] => CHAINS.filter((c) => c.isTestnet); export const getChainsByType = (showTestnets: boolean): ChainConfig[] => showTestnets ? getTestnetChains() : getMainnetChains(); diff --git a/frontend/src/constants/chains/solana.ts b/frontend/src/constants/chains/solana.ts index 2f916f53..646630dc 100644 --- a/frontend/src/constants/chains/solana.ts +++ b/frontend/src/constants/chains/solana.ts @@ -19,7 +19,7 @@ export const SOLANA_NETWORKS: SolanaNetworkConfig[] = [ ]; /** Maps the `VITE_SOLANA_CLUSTER` env value to a `SOLANA_NETWORKS` entry name. */ -export const solanaNetworkNameFromCluster = (cluster: string | undefined): string => { +export const solanaNetworkNameFromCluster = (cluster: string | undefined): string => { switch ((cluster ?? '').trim().toLowerCase()) { case 'devnet': return 'Solana Devnet'; @@ -36,4 +36,4 @@ export const solanaNetworkNameFromCluster = (cluster: string | undefined): strin default: return 'Solana Local'; } -} +}; diff --git a/frontend/src/constants/interactionRoutes.ts b/frontend/src/constants/interactionRoutes.ts index 5de49756..95763ee9 100644 --- a/frontend/src/constants/interactionRoutes.ts +++ b/frontend/src/constants/interactionRoutes.ts @@ -1,30 +1,47 @@ import type { ComponentType } from 'react'; import { - BattleIcon, - EggIcon, - LevelUpIcon, - MarriageIcon, - QuillIcon, - TrainIcon, + BattleIcon, + EggIcon, + LevelUpIcon, + MarriageIcon, + QuillIcon, + TrainIcon, } from '@components/ui/icon'; /** Internal action id (`interactions/:action`; `rename` segment → changename). */ -export type InteractionAction = 'breed' | 'battle' | 'levelup' | 'train' | 'marriage' | 'changename'; +export type InteractionAction = + | 'breed' + | 'battle' + | 'levelup' + | 'train' + | 'marriage' + | 'changename'; export type StandaloneInteractionHeader = { - Icon: ComponentType<{ size?: number | string }>; - label: string; - sub: string; + Icon: ComponentType<{ size?: number | string }>; + label: string; + sub: string; }; /** Standalone page titles for `/breed` … `/rename` (dashboard hub uses its own header). */ -export const STANDALONE_INTERACTION_HEADERS: Record = { - breed: { Icon: EggIcon, label: 'Breeding Lab', sub: 'Breed two pets to create a new one' }, - battle: { Icon: BattleIcon, label: 'Battle Arena', sub: 'Pick two pets to fight' }, - levelup: { Icon: LevelUpIcon, label: 'Level Up', sub: 'Pay a small fee to level up your pet' }, - train: { Icon: TrainIcon, label: 'Training Ground', sub: 'Pay a level-scaled fee for an XP boost' }, - marriage: { Icon: MarriageIcon, label: 'Marriage', sub: 'Marry two pets to unlock cross-owner breeding' }, - changename: { Icon: QuillIcon, label: 'Rename Pet', sub: "Change your pet's name" }, +export const STANDALONE_INTERACTION_HEADERS: Record< + InteractionAction, + StandaloneInteractionHeader +> = { + breed: { Icon: EggIcon, label: 'Breeding Lab', sub: 'Breed two pets to create a new one' }, + battle: { Icon: BattleIcon, label: 'Battle Arena', sub: 'Pick two pets to fight' }, + levelup: { Icon: LevelUpIcon, label: 'Level Up', sub: 'Pay a small fee to level up your pet' }, + train: { + Icon: TrainIcon, + label: 'Training Ground', + sub: 'Pay a level-scaled fee for an XP boost', + }, + marriage: { + Icon: MarriageIcon, + label: 'Marriage', + sub: 'Marry two pets to unlock cross-owner breeding', + }, + changename: { Icon: QuillIcon, label: 'Rename Pet', sub: "Change your pet's name" }, }; /** Dashboard home (hub + gallery). */ @@ -40,15 +57,15 @@ export const RENAME_PATH = '/rename'; /** Routes where the layout shows only the interaction flow (gallery hidden). */ export const INTERACTION_ROUTES: readonly string[] = [ - BREED_PATH, - BATTLE_PATH, - LEVELUP_PATH, - TRAIN_PATH, - MARRIAGE_PATH, - RENAME_PATH, + BREED_PATH, + BATTLE_PATH, + LEVELUP_PATH, + TRAIN_PATH, + MARRIAGE_PATH, + RENAME_PATH, ]; -export const isInteractionRoute = (pathname: string): boolean => { - const path = pathname.replace(/\/$/, '') || '/'; - return INTERACTION_ROUTES.includes(path); -} +export const isInteractionRoute = (pathname: string): boolean => { + const path = pathname.replace(/\/$/, '') || '/'; + return INTERACTION_ROUTES.includes(path); +}; diff --git a/frontend/src/contexts/dynamic/index.tsx b/frontend/src/contexts/dynamic/index.tsx index b3c120c7..e0855026 100644 --- a/frontend/src/contexts/dynamic/index.tsx +++ b/frontend/src/contexts/dynamic/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { DynamicContextProvider } from '@dynamic-labs/sdk-react-core'; -import { EthereumWalletConnectors } from "@dynamic-labs/ethereum"; +import { EthereumWalletConnectors } from '@dynamic-labs/ethereum'; import { SolanaWalletConnectors } from '@dynamic-labs/solana'; import { DynamicWagmiConnector } from '@dynamic-labs/wagmi-connector'; import { CHAINS, SOLANA_NETWORKS } from '@constants/chains'; @@ -10,8 +10,10 @@ interface DynamicProviderProps { } // Convert existing chain configs to Dynamic.xyz format -const customEvmNetworks = CHAINS.map(chainConfig => ({ - blockExplorerUrls: chainConfig.chain.blockExplorers?.default?.url ? [chainConfig.chain.blockExplorers.default.url] : [], +const customEvmNetworks = CHAINS.map((chainConfig) => ({ + blockExplorerUrls: chainConfig.chain.blockExplorers?.default?.url + ? [chainConfig.chain.blockExplorers.default.url] + : [], chainId: chainConfig.chain.id, chainName: chainConfig.chain.name, iconUrls: ['https://app.dynamic.xyz/assets/networks/eth.svg'], @@ -27,11 +29,16 @@ const customEvmNetworks = CHAINS.map(chainConfig => ({ vanityName: chainConfig.name, })); -const customSolanaNetworks = SOLANA_NETWORKS.map(network => ({ +const customSolanaNetworks = SOLANA_NETWORKS.map((network) => ({ blockExplorerUrls: ['https://explorer.solana.com'], - chainId: network.name === 'Solana Local' ? 999 : - network.name === 'Solana Mainnet' ? 101 : - network.name === 'Solana Devnet' ? 103 : 102, + chainId: + network.name === 'Solana Local' + ? 999 + : network.name === 'Solana Mainnet' + ? 101 + : network.name === 'Solana Devnet' + ? 103 + : 102, chainName: network.name, iconUrls: ['https://app.dynamic.xyz/assets/networks/solana.svg'], name: 'Solana', @@ -41,9 +48,14 @@ const customSolanaNetworks = SOLANA_NETWORKS.map(network => ({ symbol: 'SOL', iconUrl: 'https://app.dynamic.xyz/assets/networks/solana.svg', }, - networkId: network.name === 'Solana Local' ? 999 : - network.name === 'Solana Mainnet' ? 101 : - network.name === 'Solana Devnet' ? 103 : 102, + networkId: + network.name === 'Solana Local' + ? 999 + : network.name === 'Solana Mainnet' + ? 101 + : network.name === 'Solana Devnet' + ? 103 + : 102, rpcUrls: [network.rpcUrl], vanityName: network.name, })); @@ -66,12 +78,10 @@ export const DynamicProvider: React.FC = ({ children }) => evmNetworks: customEvmNetworks, solNetworks: customSolanaNetworks, }, - initialAuthenticationMode: 'connect-only' + initialAuthenticationMode: 'connect-only', }} > - - {children} - + {children} ); }; diff --git a/frontend/src/hooks/battle/useBattleOutcome.ts b/frontend/src/hooks/battle/useBattleOutcome.ts index e5b06e28..96b08722 100644 --- a/frontend/src/hooks/battle/useBattleOutcome.ts +++ b/frontend/src/hooks/battle/useBattleOutcome.ts @@ -34,7 +34,11 @@ export interface UseBattleOutcome { * only supplies `leveledUp`. On Solana (no event surfaced here) it falls back to * diffing the fighter's win/loss stats against a pre-battle snapshot. */ -export const useBattleOutcome = ({ pets, selectedPet1, petsLoading }: UseBattleOutcomeArgs): UseBattleOutcome => { +export const useBattleOutcome = ({ + pets, + selectedPet1, + petsLoading, +}: UseBattleOutcomeArgs): UseBattleOutcome => { const [battleOutcome, setBattleOutcome] = useState(null); // Snapshot taken before battle.mutate; cleared after the outcome resolves. const preBattleStatsRef = useRef(null); @@ -78,21 +82,40 @@ export const useBattleOutcome = ({ pets, selectedPet1, petsLoading }: UseBattleO // The stat diff supplies `leveledUp`, and the win/lose result when no // authoritative on-chain result was applied (Solana). useEffect(() => { - if (!pendingOutcomeRef.current || !selectedPet1 || !preBattleStatsRef.current || petsLoading) return; + if ( + !pendingOutcomeRef.current || + !selectedPet1 || + !preBattleStatsRef.current || + petsLoading + ) + return; const updatedFighter = pets.find((p) => p.id === selectedPet1); if (!updatedFighter) return; - const { winCount: prevWin, lossCount: prevLoss, level: prevLevel } = preBattleStatsRef.current; + const { + winCount: prevWin, + lossCount: prevLoss, + level: prevLevel, + } = preBattleStatsRef.current; // Stats haven't refreshed yet — wait for the next update. if (updatedFighter.winCount === prevWin && updatedFighter.lossCount === prevLoss) return; setBattleOutcome({ - result: authoritativeRef.current ?? (updatedFighter.winCount > prevWin ? 'victory' : 'defeat'), + result: + authoritativeRef.current ?? + (updatedFighter.winCount > prevWin ? 'victory' : 'defeat'), leveledUp: updatedFighter.level > prevLevel, }); pendingOutcomeRef.current = false; }, [pets, selectedPet1, petsLoading]); - return { battleOutcome, snapshotFighterStats, markPendingOutcome, applyResolvedOutcome, clearSnapshot, resetOutcome }; -} + return { + battleOutcome, + snapshotFighterStats, + markPendingOutcome, + applyResolvedOutcome, + clearSnapshot, + resetOutcome, + }; +}; diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index 71bbd8a2..419f5063 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -15,7 +15,10 @@ import { import { DASHBOARD_HOME } from '@constants/interactionRoutes'; import { formatTxHashHint } from '@hooks/usePetError'; import { usePetErrorToast } from '@hooks/usePetErrorToast'; -import { pickRandomOpponent, sortOpponentsByMatch } from '@components/pet/interactions/panels/battle/battle-matchmaking'; +import { + pickRandomOpponent, + sortOpponentsByMatch, +} from '@components/pet/interactions/panels/battle/battle-matchmaking'; import { useBattleOutcome } from './useBattleOutcome'; import { useResultDialogue } from './useResultDialogue'; import { @@ -54,7 +57,7 @@ export interface UseBattlePanel { * detection and dialogue — live in their own hooks (`useBattleOutcome`, * `useResultDialogue`) and are composed below. */ -export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBattlePanel => { +export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBattlePanel => { const navigate = useNavigate(); const capabilities = useChainCapabilities(); const { pets, refetch, isLoading: petsLoading } = usePetList(); @@ -92,16 +95,19 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat // Outcome detection (snapshot diff against refreshed on-chain stats). const outcome = useBattleOutcome({ pets, selectedPet1, petsLoading }); - const handleSuccess = useCallback((result: BattleResolvedResult | null) => { - setShowResult(true); - setValidationError(null); - outcome.markPendingOutcome(); - // EVM: BattleResolved is authoritative — petId1 is the player's pet, so - // firstWins is the player's verdict. Solana resolves via the stat diff. - if (result) outcome.applyResolvedOutcome(result.firstWins); - void refetch(); - void refetchOpponents(); - }, [outcome, refetch, refetchOpponents]); + const handleSuccess = useCallback( + (result: BattleResolvedResult | null) => { + setShowResult(true); + setValidationError(null); + outcome.markPendingOutcome(); + // EVM: BattleResolved is authoritative — petId1 is the player's pet, so + // firstWins is the player's verdict. Solana resolves via the stat diff. + if (result) outcome.applyResolvedOutcome(result.firstWins); + void refetch(); + void refetchOpponents(); + }, + [outcome, refetch, refetchOpponents], + ); const battle = useBattlePets({ onSuccess: handleSuccess }); // AI pre-fight taunts — generated on Start Battle, in parallel with the wallet. @@ -130,11 +136,7 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat [opponents, fighterLevel], ); - const winEstimate = useWinEstimate( - activeChainKind, - selectedPet1 || null, - opponent?.id ?? null, - ); + const winEstimate = useWinEstimate(activeChainKind, selectedPet1 || null, opponent?.id ?? null); const isArenaReady = Boolean(selectedFighter && opponent && !battle.isPending && !showResult); const isArenaFighting = battle.isPending; @@ -161,7 +163,9 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat const pendingLabel = usesSwitchboardVrf ? 'Generating randomness…' : 'Starting Battle...'; // Fall back to the retained battle id: the lifecycle auto-resets (hash // cleared) once the battle settles, but the hint should keep showing. - const hashHint = usesSwitchboardVrf ? formatTxHashHint(battle.hash ?? settledBattleId ?? undefined) : null; + const hashHint = usesSwitchboardVrf + ? formatTxHashHint(battle.hash ?? settledBattleId ?? undefined) + : null; const startBattle = useCallback(() => { if (!selectedPet1 || !opponent) { @@ -404,27 +408,33 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat const preResultStatus = rematchPending ? 'Preparing rematch…' : battle.phase === 'awaiting-vrf' - ? 'Awaiting randomness…' - : battle.phase === 'settling' - ? 'Settling the battle…' - : battle.phase === 'resolving' - ? 'Resolving the outcome…' - : battle.isConfirming - ? 'Confirming on-chain…' - : battle.isPending - ? 'Awaiting your wallet…' - : null; + ? 'Awaiting randomness…' + : battle.phase === 'settling' + ? 'Settling the battle…' + : battle.phase === 'resolving' + ? 'Resolving the outcome…' + : battle.isConfirming + ? 'Confirming on-chain…' + : battle.isPending + ? 'Awaiting your wallet…' + : null; const battleButtonLabel = taunts.isLoading ? 'Facing off…' : battle.isPending - ? pendingLabel - : battle.isConfirming - ? 'Confirming...' - : 'Start Battle'; + ? pendingLabel + : battle.isConfirming + ? 'Confirming...' + : 'Start Battle'; const battleDisabled = - battle.isPending || battle.isConfirming || rematchPending || overlayOpen || - !selectedPet1 || !selectedOpponent || showResult || hasPendingBattle; + battle.isPending || + battle.isConfirming || + rematchPending || + overlayOpen || + !selectedPet1 || + !selectedOpponent || + showResult || + hasPendingBattle; const randomMatchDisabled = !canRandomMatch || battle.isPending || showResult; const overlay: BattleOverlayProps = { @@ -486,4 +496,4 @@ export const useBattlePanel = ({ isStandaloneView }: UseBattlePanelArgs): UseBat hashHint, receipt: battle.lifecycle, }; -} +}; diff --git a/frontend/src/hooks/battle/useResultDialogue.ts b/frontend/src/hooks/battle/useResultDialogue.ts index 82482ca6..c223f390 100644 --- a/frontend/src/hooks/battle/useResultDialogue.ts +++ b/frontend/src/hooks/battle/useResultDialogue.ts @@ -8,7 +8,10 @@ import { type PetChain, } from '@shared/core'; import type { BattleOutcome } from '@components/pet/interactions/panels/battle/types'; -import { toDialoguePet, type BattlePersonas } from '@components/pet/interactions/panels/battle/battle-utils'; +import { + toDialoguePet, + type BattlePersonas, +} from '@components/pet/interactions/panels/battle/battle-utils'; interface UseResultDialogueArgs { activeChainKind: PetChain | null; @@ -50,14 +53,21 @@ export const useResultDialogue = ({ personasRef, battleOutcome, showResult, -}: UseResultDialogueArgs): UseResultDialogue => { +}: UseResultDialogueArgs): UseResultDialogue => { const [resultDialogueDone, setResultDialogueDone] = useState(false); const dialogueWinner = - battleOutcome === null ? null : battleOutcome.result === 'victory' ? 'attacker' : 'defender'; + battleOutcome === null + ? null + : battleOutcome.result === 'victory' + ? 'attacker' + : 'defender'; const attackerDialogueInput = useMemo( - () => (selectedFighter ? toDialoguePet(selectedFighter) : personasRef.current?.attacker ?? null), + () => + selectedFighter + ? toDialoguePet(selectedFighter) + : personasRef.current?.attacker ?? null, [selectedFighter, personasRef], ); const defenderDialogueInput = useMemo( @@ -91,7 +101,8 @@ export const useResultDialogue = ({ // brief pre-fetch window. markResultDialogueDone handles the case where it plays. useEffect(() => { if (battleOutcome === null) return; - const nothingToPlay = resultTurns.length === 0 && (dialogueFetched || settledBattleId === null); + const nothingToPlay = + resultTurns.length === 0 && (dialogueFetched || settledBattleId === null); if (nothingToPlay) setResultDialogueDone(true); }, [battleOutcome, dialogueFetched, resultTurns.length, settledBattleId]); @@ -107,4 +118,4 @@ export const useResultDialogue = ({ attackerName: attackerDialogueInput?.name ?? 'Your pet', defenderName: defenderDialogueInput?.name ?? 'Opponent', }; -} +}; diff --git a/frontend/src/hooks/useNotifyError.ts b/frontend/src/hooks/useNotifyError.ts index 7929b177..382e0db6 100644 --- a/frontend/src/hooks/useNotifyError.ts +++ b/frontend/src/hooks/useNotifyError.ts @@ -16,5 +16,4 @@ export const useNotifyError = () => { }, [toast], ); -} - +}; diff --git a/frontend/src/hooks/usePetError.ts b/frontend/src/hooks/usePetError.ts index de7d81bc..d766fed7 100644 --- a/frontend/src/hooks/usePetError.ts +++ b/frontend/src/hooks/usePetError.ts @@ -1,6 +1,6 @@ export { usePetError, type PetError } from '@shared/core'; /** Trims a tx hash to a short readable hint — UI-only, Solana path only. */ -export const formatTxHashHint = (hash: string | undefined): string | null => { +export const formatTxHashHint = (hash: string | undefined): string | null => { return hash ? `${hash.slice(0, 8)}…` : null; -} +}; diff --git a/frontend/src/hooks/usePetErrorToast.ts b/frontend/src/hooks/usePetErrorToast.ts index 5b3c914d..1331b389 100644 --- a/frontend/src/hooks/usePetErrorToast.ts +++ b/frontend/src/hooks/usePetErrorToast.ts @@ -11,14 +11,9 @@ export const usePetErrorToast = ( receiptError: Error | null | undefined, validationError: string | null, fallbackMessage: string, -): void => { +): void => { const toast = useToast(); - const display = usePetError( - mutationError, - receiptError, - validationError, - fallbackMessage, - ); + const display = usePetError(mutationError, receiptError, validationError, fallbackMessage); const lastKeyRef = useRef(null); useEffect(() => { @@ -59,4 +54,4 @@ export const usePetErrorToast = ( validationError, toast, ]); -} +}; diff --git a/frontend/src/hooks/useTxErrorToast.ts b/frontend/src/hooks/useTxErrorToast.ts index 7578d800..32af1947 100644 --- a/frontend/src/hooks/useTxErrorToast.ts +++ b/frontend/src/hooks/useTxErrorToast.ts @@ -29,4 +29,4 @@ export const useTxErrorToast = ( toast.error(parsed.message); }, [parsed, writeError, toast]); -} +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index c0dc4363..a729ca90 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,70 +2,75 @@ @import './styles/messages.css'; :root { - font-family: var(--content-font); - line-height: 1.5; - font-weight: 400; + font-family: var(--content-font); + line-height: 1.5; + font-weight: 400; - color-scheme: dark; - color: var(--cp-body-text-dark); - background-color: var(--cp-body-bg-dark); + color-scheme: dark; + color: var(--cp-body-text-dark); + background-color: var(--cp-body-bg-dark); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -h1, h2, h3, h4, h5, h6 { - font-family: var(--title-font); - letter-spacing: 0.5px; +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--title-font); + letter-spacing: 0.5px; } a { - font-weight: 500; - color: var(--cp-link); - text-decoration: inherit; - transition: color 0.2s ease, text-shadow 0.2s ease; + font-weight: 500; + color: var(--cp-link); + text-decoration: inherit; + transition: color 0.2s ease, text-shadow 0.2s ease; - &:hover { - color: var(--cp-link-hover); - text-shadow: 0 0 8px var(--neon-text-glow); - } + &:hover { + color: var(--cp-link-hover); + text-shadow: 0 0 8px var(--neon-text-glow); + } } body { - margin: 0; - min-width: 320px; - min-height: 100vh; - background: inherit; - color: inherit; + margin: 0; + min-width: 320px; + min-height: 100vh; + background: inherit; + color: inherit; } h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } button { - border-radius: 2px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 600; - font-family: inherit; - letter-spacing: 0.4px; - background-color: rgb(5 13 30 / 92%); - color: var(--cp-text-body-dark); - cursor: pointer; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 600; + font-family: inherit; + letter-spacing: 0.4px; + background-color: rgb(5 13 30 / 92%); + color: var(--cp-text-body-dark); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; - &:hover { - border-color: var(--cp-link); - box-shadow: 0 0 12px rgb(125 214 255 / 24%); - } + &:hover { + border-color: var(--cp-link); + box-shadow: 0 0 12px rgb(125 214 255 / 24%); + } - &:focus, - &:focus-visible { - outline: none; - } + &:focus, + &:focus-visible { + outline: none; + } } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f72f6804..3a8f662f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,14 +1,14 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; -import './index.css' +import './index.css'; import App from './App.tsx'; const rootElement = document.getElementById('root'); if (!rootElement) throw new Error('Root element not found'); createRoot(rootElement).render( - - - , -) + + + , +); diff --git a/frontend/src/petsContractParams.ts b/frontend/src/petsContractParams.ts index 34407224..35fd2137 100644 --- a/frontend/src/petsContractParams.ts +++ b/frontend/src/petsContractParams.ts @@ -1,7 +1,9 @@ import type { PetsEvmConfig } from '@shared/core'; import { evmContracts } from '@chains/ethereum/contracts'; -const evmChainId = import.meta.env.VITE_EVM_CHAIN_ID ? Number(import.meta.env.VITE_EVM_CHAIN_ID) : undefined; +const evmChainId = import.meta.env.VITE_EVM_CHAIN_ID + ? Number(import.meta.env.VITE_EVM_CHAIN_ID) + : undefined; /** v2 EVM contract config for `PetsConfigProvider` from `@shared/core`. */ export const petsContractParams: PetsEvmConfig = { diff --git a/frontend/src/styles/messages.css b/frontend/src/styles/messages.css index 5c6a5587..a7d9909e 100644 --- a/frontend/src/styles/messages.css +++ b/frontend/src/styles/messages.css @@ -18,9 +18,7 @@ background: rgb(255 110 196 / 8%); border: 1px solid rgb(255 110 196 / 38%); color: #ff9ad6; - box-shadow: - inset 0 0 12px rgb(255 110 196 / 12%), - 0 0 10px rgb(255 110 196 / 18%); + box-shadow: inset 0 0 12px rgb(255 110 196 / 12%), 0 0 10px rgb(255 110 196 / 18%); text-shadow: 0 0 10px rgb(255 110 196 / 32%); &.user-rejection { @@ -37,9 +35,7 @@ background: rgb(255 181 67 / 8%); border-color: rgb(255 181 67 / 38%); color: #ffd07b; - box-shadow: - inset 0 0 12px rgb(255 181 67 / 12%), - 0 0 10px rgb(255 181 67 / 18%); + box-shadow: inset 0 0 12px rgb(255 181 67 / 12%), 0 0 10px rgb(255 181 67 / 18%); text-shadow: 0 0 10px rgb(255 181 67 / 32%); } } @@ -48,9 +44,7 @@ background: rgb(0 255 157 / 8%); border: 1px solid rgb(0 255 157 / 38%); color: #9effd4; - box-shadow: - inset 0 0 12px rgb(0 255 157 / 14%), - 0 0 10px rgb(0 255 157 / 22%); + box-shadow: inset 0 0 12px rgb(0 255 157 / 14%), 0 0 10px rgb(0 255 157 / 22%); text-shadow: 0 0 10px rgb(0 255 157 / 32%); } diff --git a/frontend/src/styles/variables.css b/frontend/src/styles/variables.css index d435b3c1..e504548f 100644 --- a/frontend/src/styles/variables.css +++ b/frontend/src/styles/variables.css @@ -2,177 +2,184 @@ Palette primitives — single source for hex/rgba (dark-only theme + components) ============================================================================= */ :root { - --cp-white: #ffffff; - --cp-text-body-dark: #f3f4f6; - --cp-text-muted-dark: #c7ccd4; - --cp-text-light-dark: #9ca3af; - --cp-border-dark: #2b3240; - --cp-bg-dark: #141923; - --cp-bg-muted-dark: #1d2430; - --cp-bg-hover-dark: #273244; - - --cp-shadow-10: rgba(0, 0, 0, 0.1); - --cp-shadow-15: rgba(0, 0, 0, 0.15); - --cp-shadow-35: rgba(0, 0, 0, 0.35); - --cp-shadow-50: rgba(0, 0, 0, 0.5); - --cp-shadow-25: rgba(0, 0, 0, 0.25); - - --cp-brand-primary: #667eea; - --cp-brand-secondary: #764ba2; - - /* Splash / index root (slightly different from card `--color-background`) */ - --cp-body-text-dark: #e5e7eb; - --cp-body-bg-dark: #050812; - --cp-link: #7dd6ff; - --cp-link-hover: #b58cff; - - --cp-shadow-button: rgba(102, 126, 234, 0.3); - --cp-shadow-button-hover: rgba(102, 126, 234, 0.4); - - /* Neon tri-color — mirrors website (cyan / violet / magenta) */ - --neon-cyan: #7dd6ff; - --neon-cyan-strong: #29a8ff; - --neon-violet: #b58cff; - --neon-violet-strong: #8a62ff; - --neon-magenta: #ff7bcb; - --neon-magenta-strong: #ff6ec4; - - --neon-grid-line: rgb(255 255 255 / 3%); - --neon-base-bg: #050812; - --neon-band-bg: #070a14; - --neon-border-soft: rgb(101 131 255 / 22%); - --neon-border-strong: rgb(148 191 255 / 42%); - --neon-text-glow: rgb(185 160 255 / 50%); - - --neon-gradient-line: linear-gradient(90deg, #7dd6ff 0%, #b58cff 60%, #ff7bcb 100%); - --neon-gradient-wash: - radial-gradient(circle at 20% 0%, rgb(87 57 230 / 30%) 0%, transparent 38%), - radial-gradient(circle at 80% 20%, rgb(32 163 255 / 14%) 0%, transparent 44%), - var(--neon-base-bg); + --cp-white: #ffffff; + --cp-text-body-dark: #f3f4f6; + --cp-text-muted-dark: #c7ccd4; + --cp-text-light-dark: #9ca3af; + --cp-border-dark: #2b3240; + --cp-bg-dark: #141923; + --cp-bg-muted-dark: #1d2430; + --cp-bg-hover-dark: #273244; + + --cp-shadow-10: rgba(0, 0, 0, 0.1); + --cp-shadow-15: rgba(0, 0, 0, 0.15); + --cp-shadow-35: rgba(0, 0, 0, 0.35); + --cp-shadow-50: rgba(0, 0, 0, 0.5); + --cp-shadow-25: rgba(0, 0, 0, 0.25); + + --cp-brand-primary: #667eea; + --cp-brand-secondary: #764ba2; + + /* Splash / index root (slightly different from card `--color-background`) */ + --cp-body-text-dark: #e5e7eb; + --cp-body-bg-dark: #050812; + --cp-link: #7dd6ff; + --cp-link-hover: #b58cff; + + --cp-shadow-button: rgba(102, 126, 234, 0.3); + --cp-shadow-button-hover: rgba(102, 126, 234, 0.4); + + /* Neon tri-color — mirrors website (cyan / violet / magenta) */ + --neon-cyan: #7dd6ff; + --neon-cyan-strong: #29a8ff; + --neon-violet: #b58cff; + --neon-violet-strong: #8a62ff; + --neon-magenta: #ff7bcb; + --neon-magenta-strong: #ff6ec4; + + --neon-grid-line: rgb(255 255 255 / 3%); + --neon-base-bg: #050812; + --neon-band-bg: #070a14; + --neon-border-soft: rgb(101 131 255 / 22%); + --neon-border-strong: rgb(148 191 255 / 42%); + --neon-text-glow: rgb(185 160 255 / 50%); + + --neon-gradient-line: linear-gradient(90deg, #7dd6ff 0%, #b58cff 60%, #ff7bcb 100%); + --neon-gradient-wash: radial-gradient( + circle at 20% 0%, + rgb(87 57 230 / 30%) 0%, + transparent 38% + ), + radial-gradient(circle at 80% 20%, rgb(32 163 255 / 14%) 0%, transparent 44%), + var(--neon-base-bg); } /* ============================================================================= Semantic tokens (layout shell, gallery, modals) ============================================================================= */ :root { - --color-primary: var(--cp-brand-primary); - --color-secondary: var(--cp-brand-secondary); - --color-text: var(--cp-text-body-dark); - --color-text-muted: var(--cp-text-muted-dark); - --color-text-light: var(--cp-text-light-dark); - --color-border: var(--cp-border-dark); - --color-background: var(--cp-bg-dark); - --color-background-light: var(--cp-bg-muted-dark); - --color-background-hover: var(--cp-bg-hover-dark); - --color-shadow: var(--cp-shadow-35); - --color-shadow-hover: var(--cp-shadow-50); - --color-shadow-button: var(--cp-shadow-button); - --color-shadow-button-hover: var(--cp-shadow-button-hover); + --color-primary: var(--cp-brand-primary); + --color-secondary: var(--cp-brand-secondary); + --color-text: var(--cp-text-body-dark); + --color-text-muted: var(--cp-text-muted-dark); + --color-text-light: var(--cp-text-light-dark); + --color-border: var(--cp-border-dark); + --color-background: var(--cp-bg-dark); + --color-background-light: var(--cp-bg-muted-dark); + --color-background-hover: var(--cp-bg-hover-dark); + --color-shadow: var(--cp-shadow-35); + --color-shadow-hover: var(--cp-shadow-50); + --color-shadow-button: var(--cp-shadow-button); + --color-shadow-button-hover: var(--cp-shadow-button-hover); } /* ============================================================================= Typography — matches `website/` (Orbitron titles + Inter body) ============================================================================= */ :root { - --title-font: 'Orbitron', 'Eurostile', 'Bank Gothic', system-ui, sans-serif; - --content-font: 'Inter', 'Segoe UI', system-ui, sans-serif; + --title-font: 'Orbitron', 'Eurostile', 'Bank Gothic', system-ui, sans-serif; + --content-font: 'Inter', 'Segoe UI', system-ui, sans-serif; } /* ============================================================================= Main layout scale (rem) — shared by header, interactions, modals ============================================================================= */ :root { - --spacing-xs: 0.5rem; - --spacing-sm: 1rem; - --spacing-md: 1.5rem; - --spacing-lg: 2rem; - --spacing-xl: 2.5rem; - --spacing-2xl: 3rem; - - --border-radius: 16px; - --border-radius-sm: 8px; - - --font-size-sm: 0.875rem; - --font-size-base: 1rem; - --font-size-lg: 1.125rem; - --font-size-xl: 1.25rem; - --font-size-2xl: 1.5rem; - --font-size-3xl: 2rem; - --font-size-4xl: 2.5rem; - - --transition: all 0.3s ease; - --transition-fast: all 0.2s ease; - - --z-header: 50; - --z-modal: 1000; - - /* Space reserved for fixed `.main-header` (row: title + wallet; must cover wrap height on small screens) */ - --main-header-offset: 100px; - - --gradient-primary: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%); + --spacing-xs: 0.5rem; + --spacing-sm: 1rem; + --spacing-md: 1.5rem; + --spacing-lg: 2rem; + --spacing-xl: 2.5rem; + --spacing-2xl: 3rem; + + --border-radius: 16px; + --border-radius-sm: 8px; + + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 2rem; + --font-size-4xl: 2.5rem; + + --transition: all 0.3s ease; + --transition-fast: all 0.2s ease; + + --z-header: 50; + --z-modal: 1000; + + /* Space reserved for fixed `.main-header` (row: title + wallet; must cover wrap height on small screens) */ + --main-header-offset: 100px; + + --gradient-primary: linear-gradient( + 135deg, + var(--color-primary) 0%, + var(--color-secondary) 100% + ); } @media (max-width: 768px) { - :root { - --main-header-offset: 128px; - } + :root { + --main-header-offset: 128px; + } } /* ============================================================================= Legacy aliases (App.css, older snippets) ============================================================================= */ :root { - --primary-color: #007bff; - --primary-hover: #0056b3; - --secondary-color: #6c757d; - --success-color: #28a745; - --danger-color: #dc3545; - --warning-color: #17a2b8; - --warning-hover: #138496; - --info-color: #007bff; - --light-color: #f8f9fa; - --dark-color: #343a40; - - --text-primary: var(--color-text); - --text-secondary: #555; - --text-muted: var(--color-text-muted); - --text-light: #fff; - - --bg-primary: var(--color-background); - --bg-secondary: var(--color-background-light); - --bg-dark: #343a40; - - --border-color: #dee2e6; - --border-light: #eee; - --border-dark: #adb5bd; - - --spacing-xxl: 24px; - --spacing-xxxl: 32px; - - --font-xs: 12px; - --font-sm: 14px; - --font-md: 16px; - --font-lg: 18px; - --font-xl: 20px; - --font-xxl: 24px; - - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-xl: 12px; - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); - --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15); - --shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.2); - - --transition-normal: 0.2s ease; - --transition-slow: 0.3s ease; - - --z-dropdown: 1000; - --z-sticky: 1020; - --z-fixed: 1030; - --z-modal-backdrop: 1040; - --z-popover: 1060; - --z-tooltip: 1070; -} \ No newline at end of file + --primary-color: #007bff; + --primary-hover: #0056b3; + --secondary-color: #6c757d; + --success-color: #28a745; + --danger-color: #dc3545; + --warning-color: #17a2b8; + --warning-hover: #138496; + --info-color: #007bff; + --light-color: #f8f9fa; + --dark-color: #343a40; + + --text-primary: var(--color-text); + --text-secondary: #555; + --text-muted: var(--color-text-muted); + --text-light: #fff; + + --bg-primary: var(--color-background); + --bg-secondary: var(--color-background-light); + --bg-dark: #343a40; + + --border-color: #dee2e6; + --border-light: #eee; + --border-dark: #adb5bd; + + --spacing-xxl: 24px; + --spacing-xxxl: 32px; + + --font-xs: 12px; + --font-sm: 14px; + --font-md: 16px; + --font-lg: 18px; + --font-xl: 20px; + --font-xxl: 24px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.15); + --shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.2); + + --transition-normal: 0.2s ease; + --transition-slow: 0.3s ease; + + --z-dropdown: 1000; + --z-sticky: 1020; + --z-fixed: 1030; + --z-modal-backdrop: 1040; + --z-popover: 1060; + --z-tooltip: 1070; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index eaca6a9e..3bf47301 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -24,4 +24,4 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b814576f..32e0b437 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.0.1) + prettier: + specifier: 2.8.8 + version: 2.8.8 typescript: specifier: ~5.8.3 version: 5.8.3 @@ -24721,7 +24724,7 @@ snapshots: isomorphic-timers-promises@1.0.1: {} - isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)): + isomorphic-ws@4.0.1(ws@7.5.10): dependencies: ws: 7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -24845,7 +24848,7 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + isomorphic-ws: 4.0.1(ws@7.5.10) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 8.3.2 diff --git a/shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c b/shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c deleted file mode 100644 index 87939fe7..00000000 --- a/shared/src/utils/common/time.ts.tmp.12564.cb12c6a83d4c +++ /dev/null @@ -1,8 +0,0 @@ -/** Format a Unix expiry timestamp (seconds) as a short relative-time label. */ -export const formatExpiry = (expirySec: number): string => { - const diff = expirySec - Math.floor(Date.now() / 1000); - if (diff <= 0) return 'Expired'; - if (diff < 3600) return `${Math.ceil(diff / 60)}m`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; - return `${Math.floor(diff / 86400)}d`; -}; From 5b9facc5d24c40a5ce8c741fcaaa768baa32c96e Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 17:34:12 -0400 Subject: [PATCH 36/48] refactor(frontend): colocate interaction panel CSS --- frontend/CODE_QUALITY_REFACTOR.md | 51 +- .../pet/interactions/interactions.css | 403 ++++++++ .../pet/interactions/overview/index.css | 878 +----------------- .../pet/interactions/overview/index.tsx | 1 + .../interactions/panels/marriage/index.css | 470 ++++++++++ .../interactions/panels/marriage/index.tsx | 1 + .../pet/interactions/standalone/index.tsx | 2 +- 7 files changed, 926 insertions(+), 880 deletions(-) create mode 100644 frontend/src/components/pet/interactions/interactions.css create mode 100644 frontend/src/components/pet/interactions/panels/marriage/index.css diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 231f4f0c..b00cf411 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -36,7 +36,7 @@ session can resume without re-deriving context. | # | Step | Status | |---|------|--------| | 1 | Tooling: Prettier + `.editorconfig` + reformat | DONE | -| 2 | Colocate panel CSS (split `overview/index.css`) | TODO | +| 2 | Colocate panel CSS (split `overview/index.css`) | DONE | | 3 | Extract shared `useSpousePet` hook | TODO | | 4 | Decompose `breed` panel into orchestrator + parts | TODO | | 5 | Extract `pet-gallery` cooldown logic into a hook | TODO | @@ -105,7 +105,7 @@ Co-Authored-By: Claude Opus 4.8 --- ## Step 2 — Colocate panel CSS (split `overview/index.css`) -**Status:** TODO +**Status:** DONE **Goal:** Each panel owns and imports its own stylesheet; remove cross-component CSS coupling. @@ -134,9 +134,49 @@ already colocate correctly; bring the rest in line. **Risk:** CSS regressions are visual; verify each standalone page and the hub. -**Outcome:** _(fill after completion)_ +**Outcome (done):** +- Split the 1241-line `overview/index.css` into three files (pure move, no rule edits): + - **NEW** `interactions/interactions.css` (403 lines) — shared tokens + (`.dashboard-panel.pet-interactions` `--zi-*`), `.interface`, `.picker`/`.field`, + `.name-input`, `.action-button`, `.action-controls`, `.cancel-button`, `.win-estimate`, + `.transaction-info`, `.interaction-standalone-header`, `.help-text`, messages. + - **NEW** `panels/marriage/index.css` (470 lines) — all `.marriage-*`, `.proposal-*`, + `.sent-proposals-*`, `.accept-*`, `.propose-button`, `.confirm-*`, modal styles. + - `overview/index.css` (368 lines) — now hub-card styles only (`.action-buttons`, + `.breeding-lab-card`, `.battle-arena-card`, `.feature-action-card`, hub buttons). +- Import wiring: + - `overview/index.tsx` now imports `../interactions.css` + `./index.css`. + - `standalone/index.tsx` now imports `interactions.css` (was importing `overview/index.css` + — the fragile sibling coupling is gone). + - `marriage/index.tsx` now imports its own `./index.css`. + - `battle`/`breed` unchanged: they already colocate CSS and get shared primitives from the + wrapper (same load order as before — verified no regression). +- **Verified:** no component imports another's `index.css`; `format:check` clean; `tsc -b` 0; + `eslint .` 0; **272/272 tests pass**; `vite build` succeeds and the built CSS contains the + shared (`action-button`, `win-estimate`), hub (`feature-action-card`), and marriage + (`marriage-card`, `marriage-heartbeat`) selectors. +- **Note:** styles were moved byte-for-byte (then Prettier-normalized), so visual-regression + risk is minimal, but a quick in-app look at the hub + each standalone page is still wise. +- **Dead CSS deferred to Step 8:** `.breed-button`, `.battle-button`, `.transaction-info` are + unused (only `lab-breed-button` is real); kept in `interactions.css` for now to keep this + step a pure move. Added a reminder under Step 8. -**Commit message:** _(fill after completion)_ +**Commit message:** +``` +refactor(frontend): colocate interaction panel CSS + +Split the 1281-line overview/index.css into three concerns: shared tokens +and form primitives (interactions.css), hub-card styles (overview/index.css), +and marriage-specific styles (panels/marriage/index.css). Wire each surface +to import what it needs; standalone pages no longer import the overview +component's stylesheet. + +Pure move (Prettier-normalized) — no rule changes. Typecheck, lint, all 272 +tests, and a production build pass; built CSS contains the shared, hub, and +marriage selectors. + +Co-Authored-By: Claude Opus 4.8 +``` --- @@ -284,6 +324,8 @@ behavior preserved; typecheck + lint + tests pass. using `styles/variables.css` tokens where practical. - Optional: normalize a few camelCase non-hook filenames (`constants/interactionRoutes.ts`, `petsContractParams.ts`) only if it doesn't churn imports excessively. +- **Remove dead CSS** carried over from Step 2: `.breed-button`, `.battle-button`, + `.transaction-info` in `interactions/interactions.css` (verified unused in Step 2). **Acceptance:** Lint clean; no visual/behavior change. @@ -295,3 +337,4 @@ behavior preserved; typecheck + lint + tests pass. ## Change log - 2026-06-22 — Step 1 — chore(frontend): add prettier + editorconfig and reformat src +- 2026-06-22 — Step 2 — refactor(frontend): colocate interaction panel CSS diff --git a/frontend/src/components/pet/interactions/interactions.css b/frontend/src/components/pet/interactions/interactions.css new file mode 100644 index 00000000..dc60816a --- /dev/null +++ b/frontend/src/components/pet/interactions/interactions.css @@ -0,0 +1,403 @@ +/* Pet-interactions specific tokens + content styles. + Shared surface / title-bar / heading / description / state-body styles live in + `common/dashboard-panel/index.css`. */ + +.dashboard-panel.pet-interactions { + --zi-card-bg: linear-gradient(180deg, rgb(10 16 32 / 92%), rgb(6 10 22 / 94%)); + --zi-surface-bg: rgb(5 13 30 / 78%); + --zi-text: #f6f3ff; + --zi-text-muted: rgb(195 210 255 / 70%); + --zi-border: var(--neon-border-soft); + --zi-border-strong: var(--neon-border-strong); + --zi-glow-soft: 0 0 0 1px rgb(125 214 255 / 14%), inset 0 0 18px rgb(125 214 255 / 6%); + --zi-focus: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); + + &.interaction-standalone { + max-width: 640px; + margin-inline: auto; + } + + /* The battle setup is a 3-column stage (fighters · arena · opponents); give it + the surrounding space instead of the narrow single-panel cap so the lanes + spread to the edges and the arena stays centered. */ + &.interaction-standalone:has(.battle-setup) { + max-width: min(1440px, 100%); + } + + .description { + margin: 4px 0 0; + font-size: clamp(0.625rem, 0.75rem, 0.875rem); + font-weight: 400; + color: var(--zi-text-muted); + text-align: center; + } + + .picker { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 20px; + + .field { + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + color: rgb(125 214 255 / 65%); + } + + select { + appearance: none; + -webkit-appearance: none; + padding: 11px 38px 11px 14px; + border: 1px solid rgb(125 214 255 / 28%); + border-radius: 4px; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.25px; + background-color: rgb(4 10 26 / 96%); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237dd6ff' stroke-opacity='.55' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: calc(100% - 13px) center; + background-size: 11px auto; + color: var(--zi-text); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), + inset 0 1px 0 rgb(125 214 255 / 8%); + + &:hover { + border-color: rgb(125 214 255 / 55%); + box-shadow: inset 0 0 20px rgb(125 214 255 / 7%), + 0 0 10px rgb(125 214 255 / 14%); + } + + &:focus { + outline: none; + border-color: var(--neon-cyan); + box-shadow: var(--zi-focus); + } + + option { + background: rgb(6 12 28); + color: var(--zi-text); + } + } + + input:not(.psd-input) { + padding: 11px 14px; + border: 1px solid rgb(125 214 255 / 28%); + border-radius: 4px; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.25px; + background: rgb(4 10 26 / 96%); + color: var(--zi-text); + caret-color: var(--neon-cyan); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), + inset 0 1px 0 rgb(125 214 255 / 8%); + + &::placeholder { + color: rgb(125 214 255 / 28%); + font-style: italic; + } + + &:hover { + border-color: rgb(125 214 255 / 55%); + } + + &:focus { + outline: none; + border-color: var(--neon-cyan); + box-shadow: var(--zi-focus); + } + } + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: 16px; + } + } + + .interface { + background: var(--zi-surface-bg); + border: 1px solid var(--zi-border); + border-radius: 12px; + padding: 20px; + margin-top: 20px; + box-shadow: inset 0 0 18px rgb(125 214 255 / 5%); + + h4 { + margin: 0 0 8px 0; + font-family: var(--title-font); + font-size: 20px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--zi-text); + text-align: center; + text-shadow: 0 0 10px var(--neon-text-glow); + + @media (max-width: 480px) { + font-size: 18px; + } + } + + p { + margin: 0 0 20px 0; + color: var(--zi-text-muted); + text-align: center; + font-size: 14px; + } + } + + .error-message, + .success-message { + text-align: center; + } +} + +.interaction-standalone-header { + text-align: center; + margin-bottom: var(--spacing-lg); + + & h3 { + margin-bottom: 8px; + } + + .sub { + margin: 0 !important; + font-size: 0.95rem; + color: var(--zi-text-muted); + } +} + +.help-text { + font-style: italic; + color: #888; + font-size: 0.9em; + margin-top: 8px; +} +.action-button { + padding: 16px 24px; + border: 1px solid rgb(125 214 255 / 50%); + border-radius: 2px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.95px; + min-width: 0; + text-align: center; + background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); + color: var(--neon-cyan); + box-shadow: inset 0 0 14px rgb(125 214 255 / 16%), 0 0 14px rgb(125 214 255 / 22%); + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + } +} + +.breed-button { + color: var(--neon-violet); + border-color: rgb(181 140 255 / 50%); + box-shadow: inset 0 0 14px rgb(181 140 255 / 16%), 0 0 14px rgb(181 140 255 / 22%); + + &:hover:not(:disabled) { + transform: translateY(-2px); + border-color: rgb(181 140 255 / 70%); + box-shadow: inset 0 0 20px rgb(181 140 255 / 24%), 0 0 22px rgb(181 140 255 / 38%); + } +} + +.battle-button { + color: var(--neon-magenta); + border-color: rgb(255 110 196 / 50%); + box-shadow: inset 0 0 14px rgb(255 110 196 / 16%), 0 0 14px rgb(255 110 196 / 22%); + + &:hover:not(:disabled) { + transform: translateY(-2px); + border-color: rgb(255 110 196 / 70%); + box-shadow: inset 0 0 20px rgb(255 110 196 / 24%), 0 0 22px rgb(255 110 196 / 38%); + } +} + +.name-input { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 20px; + + & label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + color: rgb(125 214 255 / 65%); + } + + & input { + padding: 11px 14px; + border: 1px solid rgb(125 214 255 / 28%); + border-radius: 4px; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.25px; + background: rgb(4 10 26 / 96%); + color: var(--zi-text, #f6f3ff); + caret-color: var(--neon-cyan); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), inset 0 1px 0 rgb(125 214 255 / 8%); + + &::placeholder { + color: rgb(125 214 255 / 28%); + font-style: italic; + } + + &:hover { + border-color: rgb(125 214 255 / 55%); + } + + &:focus { + outline: none; + border-color: var(--neon-cyan); + box-shadow: var(--zi-focus, 0 0 0 2px var(--neon-cyan)); + } + } +} + +.win-estimate { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + margin-block: 8px 0; + font-size: 12px; + letter-spacing: 0.4px; + min-height: 30px; + + .win-estimate-label { + color: rgb(195 210 255 / 50%); + text-transform: uppercase; + font-weight: 600; + } + + .win-estimate-value { + font-size: 20px; + font-weight: 800; + letter-spacing: -0.5px; + line-height: 1; + + &.favorable { + color: rgb(125 214 255); + text-shadow: 0 0 12px rgb(125 214 255 / 50%); + } + &.unfavorable { + color: rgb(255 130 130); + text-shadow: 0 0 12px rgb(255 130 130 / 40%); + } + } + + .win-estimate-samples { + color: rgb(195 210 255 / 30%); + font-size: 10px; + margin-inline-start: 2px; + } + + .win-estimate-loading, + .win-estimate-unavailable { + color: rgb(195 210 255 / 30%); + font-style: italic; + } +} + +.action-controls { + display: flex; + gap: 12px; + justify-content: center; + + & button { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 0.5px; + + &:first-child { + background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); + color: #9effd4; + border: 1px solid rgb(0 255 157 / 48%); + border-radius: 2px; + box-shadow: inset 0 0 14px rgb(0 255 157 / 14%), 0 0 14px rgb(0 255 157 / 22%); + + &:hover:not(:disabled) { + transform: translateY(-2px); + border-color: rgb(0 255 157 / 64%); + box-shadow: inset 0 0 20px rgb(0 255 157 / 22%), 0 0 22px rgb(0 255 157 / 36%); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + } + } + + @media (max-width: 768px) { + width: 100%; + max-width: 200px; + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + } +} + +.cancel-button { + background: rgb(5 13 30 / 92%); + color: rgb(195 210 255 / 78%); + border: 1px solid var(--neon-border-soft); + border-radius: 2px; + + &:hover { + color: #f6f3ff; + border-color: var(--neon-border-strong); + transform: translateY(-1px); + box-shadow: 0 0 12px rgb(125 214 255 / 22%); + } +} + +.transaction-info { + background: rgb(5 13 30 / 78%); + border: 1px solid rgb(125 214 255 / 18%); + border-radius: 2px; + padding: 12px; + margin-top: 16px; + font-size: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + word-break: break-all; + text-align: center; + color: var(--neon-cyan); + + & p { + margin: 4px 0; + opacity: 0.8; + } +} diff --git a/frontend/src/components/pet/interactions/overview/index.css b/frontend/src/components/pet/interactions/overview/index.css index f91a1308..0a696b90 100644 --- a/frontend/src/components/pet/interactions/overview/index.css +++ b/frontend/src/components/pet/interactions/overview/index.css @@ -1,190 +1,6 @@ -/* Pet-interactions specific tokens + content styles. - Shared surface / title-bar / heading / description / state-body styles live in - `common/dashboard-panel/index.css`. */ - -.dashboard-panel.pet-interactions { - --zi-card-bg: linear-gradient(180deg, rgb(10 16 32 / 92%), rgb(6 10 22 / 94%)); - --zi-surface-bg: rgb(5 13 30 / 78%); - --zi-text: #f6f3ff; - --zi-text-muted: rgb(195 210 255 / 70%); - --zi-border: var(--neon-border-soft); - --zi-border-strong: var(--neon-border-strong); - --zi-glow-soft: 0 0 0 1px rgb(125 214 255 / 14%), inset 0 0 18px rgb(125 214 255 / 6%); - --zi-focus: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); - - &.interaction-standalone { - max-width: 640px; - margin-inline: auto; - } - - /* The battle setup is a 3-column stage (fighters · arena · opponents); give it - the surrounding space instead of the narrow single-panel cap so the lanes - spread to the edges and the arena stays centered. */ - &.interaction-standalone:has(.battle-setup) { - max-width: min(1440px, 100%); - } - - .description { - margin: 4px 0 0; - font-size: clamp(0.625rem, 0.75rem, 0.875rem); - font-weight: 400; - color: var(--zi-text-muted); - text-align: center; - } - - .picker { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; - margin-bottom: 20px; - - .field { - display: flex; - flex-direction: column; - gap: 6px; - - label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.9px; - text-transform: uppercase; - color: rgb(125 214 255 / 65%); - } - - select { - appearance: none; - -webkit-appearance: none; - padding: 11px 38px 11px 14px; - border: 1px solid rgb(125 214 255 / 28%); - border-radius: 4px; - font-size: 13px; - font-weight: 500; - letter-spacing: 0.25px; - background-color: rgb(4 10 26 / 96%); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%237dd6ff' stroke-opacity='.55' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: calc(100% - 13px) center; - background-size: 11px auto; - color: var(--zi-text); - cursor: pointer; - transition: border-color 0.2s ease, box-shadow 0.2s ease; - box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), - inset 0 1px 0 rgb(125 214 255 / 8%); - - &:hover { - border-color: rgb(125 214 255 / 55%); - box-shadow: inset 0 0 20px rgb(125 214 255 / 7%), - 0 0 10px rgb(125 214 255 / 14%); - } - - &:focus { - outline: none; - border-color: var(--neon-cyan); - box-shadow: var(--zi-focus); - } - - option { - background: rgb(6 12 28); - color: var(--zi-text); - } - } - - input:not(.psd-input) { - padding: 11px 14px; - border: 1px solid rgb(125 214 255 / 28%); - border-radius: 4px; - font-size: 13px; - font-weight: 500; - letter-spacing: 0.25px; - background: rgb(4 10 26 / 96%); - color: var(--zi-text); - caret-color: var(--neon-cyan); - transition: border-color 0.2s ease, box-shadow 0.2s ease; - box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), - inset 0 1px 0 rgb(125 214 255 / 8%); - - &::placeholder { - color: rgb(125 214 255 / 28%); - font-style: italic; - } - - &:hover { - border-color: rgb(125 214 255 / 55%); - } - - &:focus { - outline: none; - border-color: var(--neon-cyan); - box-shadow: var(--zi-focus); - } - } - } - - @media (max-width: 768px) { - grid-template-columns: 1fr; - gap: 16px; - } - } - - .interface { - background: var(--zi-surface-bg); - border: 1px solid var(--zi-border); - border-radius: 12px; - padding: 20px; - margin-top: 20px; - box-shadow: inset 0 0 18px rgb(125 214 255 / 5%); - - h4 { - margin: 0 0 8px 0; - font-family: var(--title-font); - font-size: 20px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; - color: var(--zi-text); - text-align: center; - text-shadow: 0 0 10px var(--neon-text-glow); - - @media (max-width: 480px) { - font-size: 18px; - } - } - - p { - margin: 0 0 20px 0; - color: var(--zi-text-muted); - text-align: center; - font-size: 14px; - } - } - - .error-message, - .success-message { - text-align: center; - } -} - -.interaction-standalone-header { - text-align: center; - margin-bottom: var(--spacing-lg); - - & h3 { - margin-bottom: 8px; - } - - .sub { - margin: 0 !important; - font-size: 0.95rem; - color: var(--zi-text-muted); - } -} - -.help-text { - font-style: italic; - color: #888; - font-size: 0.9em; - margin-top: 8px; -} +/* Dashboard interactions hub (PetInteractions) — hub-card styles only. + Shared tokens/primitives live in ../interactions.css; surface/title-bar styles + live in common/dashboard-panel/index.css. */ .action-buttons { display: flex; @@ -550,691 +366,3 @@ } } } - -.action-button { - padding: 16px 24px; - border: 1px solid rgb(125 214 255 / 50%); - border-radius: 2px; - font-size: 16px; - font-weight: 700; - cursor: pointer; - transition: all 0.3s ease; - text-transform: uppercase; - letter-spacing: 0.95px; - min-width: 0; - text-align: center; - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: var(--neon-cyan); - box-shadow: inset 0 0 14px rgb(125 214 255 / 16%), 0 0 14px rgb(125 214 255 / 22%); - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } -} - -.breed-button { - color: var(--neon-violet); - border-color: rgb(181 140 255 / 50%); - box-shadow: inset 0 0 14px rgb(181 140 255 / 16%), 0 0 14px rgb(181 140 255 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(181 140 255 / 70%); - box-shadow: inset 0 0 20px rgb(181 140 255 / 24%), 0 0 22px rgb(181 140 255 / 38%); - } -} - -.battle-button { - color: var(--neon-magenta); - border-color: rgb(255 110 196 / 50%); - box-shadow: inset 0 0 14px rgb(255 110 196 / 16%), 0 0 14px rgb(255 110 196 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(255 110 196 / 70%); - box-shadow: inset 0 0 20px rgb(255 110 196 / 24%), 0 0 22px rgb(255 110 196 / 38%); - } -} - -.name-input { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 20px; - - & label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.9px; - text-transform: uppercase; - color: rgb(125 214 255 / 65%); - } - - & input { - padding: 11px 14px; - border: 1px solid rgb(125 214 255 / 28%); - border-radius: 4px; - font-size: 13px; - font-weight: 500; - letter-spacing: 0.25px; - background: rgb(4 10 26 / 96%); - color: var(--zi-text, #f6f3ff); - caret-color: var(--neon-cyan); - transition: border-color 0.2s ease, box-shadow 0.2s ease; - box-shadow: inset 0 0 20px rgb(125 214 255 / 4%), inset 0 1px 0 rgb(125 214 255 / 8%); - - &::placeholder { - color: rgb(125 214 255 / 28%); - font-style: italic; - } - - &:hover { - border-color: rgb(125 214 255 / 55%); - } - - &:focus { - outline: none; - border-color: var(--neon-cyan); - box-shadow: var(--zi-focus, 0 0 0 2px var(--neon-cyan)); - } - } -} - -.win-estimate { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 16px; - margin-block: 8px 0; - font-size: 12px; - letter-spacing: 0.4px; - min-height: 30px; - - .win-estimate-label { - color: rgb(195 210 255 / 50%); - text-transform: uppercase; - font-weight: 600; - } - - .win-estimate-value { - font-size: 20px; - font-weight: 800; - letter-spacing: -0.5px; - line-height: 1; - - &.favorable { - color: rgb(125 214 255); - text-shadow: 0 0 12px rgb(125 214 255 / 50%); - } - &.unfavorable { - color: rgb(255 130 130); - text-shadow: 0 0 12px rgb(255 130 130 / 40%); - } - } - - .win-estimate-samples { - color: rgb(195 210 255 / 30%); - font-size: 10px; - margin-inline-start: 2px; - } - - .win-estimate-loading, - .win-estimate-unavailable { - color: rgb(195 210 255 / 30%); - font-style: italic; - } -} - -.action-controls { - display: flex; - gap: 12px; - justify-content: center; - - & button { - padding: 12px 24px; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - text-transform: uppercase; - letter-spacing: 0.5px; - - &:first-child { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: #9effd4; - border: 1px solid rgb(0 255 157 / 48%); - border-radius: 2px; - box-shadow: inset 0 0 14px rgb(0 255 157 / 14%), 0 0 14px rgb(0 255 157 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(0 255 157 / 64%); - box-shadow: inset 0 0 20px rgb(0 255 157 / 22%), 0 0 22px rgb(0 255 157 / 36%); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } - } - - @media (max-width: 768px) { - width: 100%; - max-width: 200px; - } - } - - @media (max-width: 768px) { - flex-direction: column; - align-items: center; - } -} - -.cancel-button { - background: rgb(5 13 30 / 92%); - color: rgb(195 210 255 / 78%); - border: 1px solid var(--neon-border-soft); - border-radius: 2px; - - &:hover { - color: #f6f3ff; - border-color: var(--neon-border-strong); - transform: translateY(-1px); - box-shadow: 0 0 12px rgb(125 214 255 / 22%); - } -} - -.transaction-info { - background: rgb(5 13 30 / 78%); - border: 1px solid rgb(125 214 255 / 18%); - border-radius: 2px; - padding: 12px; - margin-top: 16px; - font-size: 12px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - word-break: break-all; - text-align: center; - color: var(--neon-cyan); - - & p { - margin: 4px 0; - opacity: 0.8; - } -} - -/* ── Marriage tab UI ─────────────────────────────────────────────────────── */ - -/* - * Marriage page — romantic / warm theme - * - * Palette — cherry blossom / dusty rose (high-lightness, low-saturation pinks): - * rose-dust hsl(347 50% 80%) → rgb(232 175 188) tabs, borders - * blush hsl(345 55% 89%) → rgb(244 212 220) text, light accents - * peach-warm hsl(20 45% 83%) → rgb(234 203 185) second partner warmth - * heart-pink hsl(345 45% 75%) → rgb(219 157 172) heart colour - * - * None of these are anywhere near blood-red — they are soft pastels that read - * as cherry blossom / strawberry cream against the dark background. - */ - -.marriage-interface { - display: flex; - flex-direction: column; - gap: 0; - background: linear-gradient(160deg, rgb(232 175 188 / 4%) 0%, transparent 55%); - border-radius: 0 0 10px 10px; -} - -.marriage-tabs { - display: flex; - gap: 4px; - margin-bottom: 0; - border-bottom: 1px solid rgb(232 175 188 / 20%); - padding-bottom: 0; -} - -.marriage-tab { - flex: 1; - padding: 10px 16px; - background: transparent; - border: 1px solid transparent; - border-bottom: none; - border-radius: 8px 8px 0 0; - color: rgb(232 175 188 / 55%); - font-size: 13px; - font-weight: 700; - letter-spacing: 0.4px; - text-transform: uppercase; - cursor: pointer; - transition: all 0.2s ease; - - &:hover:not(.active) { - color: rgb(232 175 188 / 88%); - background: rgb(232 175 188 / 6%); - } - - &.active { - color: #f0d5de; - background: rgb(232 175 188 / 9%); - border-color: rgb(232 175 188 / 26%); - border-bottom-color: rgb(5 13 30 / 92%); - margin-bottom: -1px; - } -} - -.marriage-tab-panel { - padding-top: 20px; -} - -.marriage-tab-hint { - font-size: 13px; - color: rgb(232 175 188 / 58%); - margin: 0 0 16px !important; - text-align: left !important; -} - -.marriage-status-section { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid rgb(232 175 188 / 16%); - display: flex; - flex-direction: column; - gap: 10px; -} - -.marriage-status-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: rgb(232 175 188 / 60%); -} - -/* ── Action buttons ───────────────────────────────────────────────────── */ - -.marriage-row-action { - padding: 4px 10px; - border-radius: 4px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - - &.divorce { - background: rgb(255 80 80 / 10%); - border: 1px solid rgb(255 80 80 / 32%); - color: #ff9a9a; - - &:hover:not(:disabled) { - background: rgb(255 80 80 / 20%); - border-color: rgb(255 80 80 / 55%); - } - } - - &.cancel { - background: rgb(255 181 67 / 10%); - border: 1px solid rgb(255 181 67 / 36%); - color: #ffd07b; - - &:hover:not(:disabled) { - background: rgb(255 181 67 / 20%); - border-color: rgb(255 181 67 / 60%); - } - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } -} - -.propose-button { - color: #ffd07b; - border-color: rgb(255 181 67 / 50%); - box-shadow: inset 0 0 14px rgb(255 181 67 / 16%), 0 0 14px rgb(255 181 67 / 22%); - grid-column: 1 / -1; - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(255 181 67 / 70%); - box-shadow: inset 0 0 20px rgb(255 181 67 / 24%), 0 0 22px rgb(255 181 67 / 38%); - } -} - -.accept-button { - color: #9effd4; - border-color: rgb(0 255 157 / 48%); - box-shadow: inset 0 0 14px rgb(0 255 157 / 14%), 0 0 14px rgb(0 255 157 / 22%); - grid-column: 1 / -1; - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(0 255 157 / 64%); - box-shadow: inset 0 0 20px rgb(0 255 157 / 22%), 0 0 22px rgb(0 255 157 / 36%); - } -} - -/* ── Marriage list & cards ────────────────────────────────────────────── */ - -.marriage-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 10px; - - &:empty::after { - content: 'No married pets yet. Send a proposal to find a match! 💕'; - display: block; - padding: 14px 12px; - font-size: 12px; - color: rgb(232 175 188 / 38%); - font-style: italic; - text-align: center; - } -} - -/* Romantic two-pet marriage card — cherry blossom palette */ -.marriage-card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border: 1px solid rgb(232 175 188 / 28%); - border-radius: 12px; - background: linear-gradient(135deg, rgb(244 212 220 / 7%) 0%, rgb(234 203 185 / 5%) 100%); - box-shadow: 0 0 20px rgb(219 157 172 / 8%), inset 0 1px 0 rgb(244 212 220 / 10%); - transition: box-shadow 0.3s ease, border-color 0.3s ease; - - &:hover { - border-color: rgb(232 175 188 / 44%); - box-shadow: 0 0 28px rgb(219 157 172 / 14%), inset 0 1px 0 rgb(244 212 220 / 14%); - } -} - -.marriage-pair { - display: flex; - align-items: center; - gap: 14px; - flex: 1; - min-width: 0; -} - -.marriage-partner { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; -} - -.partner-name { - font-size: 14px; - font-weight: 700; - color: #f0d5de; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.partner-meta { - font-size: 11px; - color: rgb(232 175 188 / 48%); -} - -/* Heart — soft candy pink, gentle pulse, no vivid red */ -.marriage-heart { - font-size: 20px; - color: #e8a0b4; - text-shadow: 0 0 10px rgb(219 157 172 / 55%); - flex-shrink: 0; - animation: marriage-heartbeat 2.2s ease-in-out infinite; -} - -@keyframes marriage-heartbeat { - 0%, - 100% { - transform: scale(1); - opacity: 0.85; - } - 35% { - transform: scale(1.2); - opacity: 1; - } - 50% { - transform: scale(1.08); - opacity: 0.95; - } -} - -/* Legacy row class kept for any stray usage */ -.marriage-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - border: 1px solid rgb(232 175 188 / 20%); - border-radius: 8px; - background: rgb(5 13 30 / 50%); -} - -.marriage-row .marriage-pet { - font-weight: 700; -} - -.marriage-row .marriage-status { - margin-left: auto; - opacity: 0.85; - font-size: var(--font-size-xs); -} - -/* Propose tab — sent proposals section */ -.sent-proposals-section { - margin-top: 20px; - padding-top: 16px; - border-top: 1px solid rgb(125 214 255 / 14%); - display: flex; - flex-direction: column; - gap: 8px; -} - -.sent-proposals-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: rgb(195 210 255 / 50%); -} - -/* Outgoing proposal card variant (amber tint vs green for incoming) */ -.outgoing-proposal { - border-color: rgb(255 181 67 / 22%) !important; - background: rgb(255 181 67 / 4%) !important; -} - -/* Empty state when all OutgoingProposalRow components return null */ -.sent-proposals-section .proposals-list:empty::after { - content: 'No pending proposals sent.'; - display: block; - padding: 10px 12px; - font-size: 12px; - color: rgb(125 214 255 / 35%); - font-style: italic; - text-align: center; -} - -/* Accept tab — tab badge (unread count) */ -.marriage-tab-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 18px; - height: 18px; - padding: 0 4px; - margin-left: 6px; - border-radius: 9px; - background: rgb(0 255 157 / 22%); - border: 1px solid rgb(0 255 157 / 48%); - color: #9effd4; - font-size: 11px; - font-weight: 700; - line-height: 1; -} - -/* Accept tab — empty state */ -.proposals-empty { - padding: 20px 12px; - font-size: 13px; - color: rgb(125 214 255 / 40%); - font-style: italic; - text-align: center; -} - -/* Accept tab — proposals list */ -.proposals-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 8px; -} - -.proposal-card { - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px 12px; - border: 1px solid rgb(0 255 157 / 22%); - border-radius: 8px; - background: rgb(0 255 157 / 4%); -} - -.proposal-pets { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 600; - flex-wrap: wrap; -} - -.proposal-proposer { - color: rgb(195 210 255 / 90%); -} - -.proposal-arrow { - color: rgb(0 255 157 / 60%); -} - -.proposal-target { - color: #9effd4; -} - -.proposal-id { - font-weight: 400; - font-size: 12px; - opacity: 0.7; -} - -.proposal-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.proposal-expiry { - font-size: 11px; - color: rgb(125 214 255 / 45%); -} - -.accept-inline { - background: rgb(0 255 157 / 10%); - border: 1px solid rgb(0 255 157 / 36%); - color: #9effd4; - padding: 4px 12px; - font-size: 12px; - - &:hover:not(:disabled) { - background: rgb(0 255 157 / 20%); - border-color: rgb(0 255 157 / 60%); - } -} - -/* Confirm accept modal */ -.marriage-confirm-overlay { - position: fixed; - inset: 0; - background: rgb(0 0 0 / 55%); - display: flex; - align-items: center; - justify-content: center; - z-index: 8000; - padding: 16px; -} - -.marriage-confirm-dialog { - background: rgb(5 13 30 / 97%); - border: 1px solid rgb(0 255 157 / 36%); - border-radius: 12px; - padding: 24px; - max-width: 380px; - width: 100%; - box-shadow: 0 0 40px rgb(0 255 157 / 14%); - display: flex; - flex-direction: column; - gap: 16px; -} - -.confirm-title { - margin: 0; - font-size: 16px; - color: #9effd4; -} - -.confirm-body { - margin: 0; - font-size: 14px; - color: rgb(195 210 255 / 80%); - line-height: 1.5; -} - -.confirm-actions { - display: flex; - gap: 10px; - justify-content: flex-end; -} - -.confirm-cancel { - padding: 8px 18px; - border-radius: 6px; - background: transparent; - border: 1px solid rgb(125 214 255 / 25%); - color: rgb(195 210 255 / 70%); - font-size: 13px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: rgb(125 214 255 / 8%); - border-color: rgb(125 214 255 / 50%); - } - - &:disabled { - opacity: 0.45; - cursor: not-allowed; - } -} - -.confirm-accept { - padding: 8px 20px !important; - font-size: 13px !important; -} diff --git a/frontend/src/components/pet/interactions/overview/index.tsx b/frontend/src/components/pet/interactions/overview/index.tsx index 26349fa3..bad1b5c3 100644 --- a/frontend/src/components/pet/interactions/overview/index.tsx +++ b/frontend/src/components/pet/interactions/overview/index.tsx @@ -35,6 +35,7 @@ import TrainPanel from '@components/pet/interactions/panels/train'; import MarriagePanel from '@components/pet/interactions/panels/marriage'; import RenamePanel from '@components/pet/interactions/panels/rename'; import StateCard from '@components/pet/interactions/state-card'; +import '../interactions.css'; import './index.css'; /** Map `interactions/:action` segment (e.g. `rename`) to internal action id. */ diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.css b/frontend/src/components/pet/interactions/panels/marriage/index.css new file mode 100644 index 00000000..4d245ca7 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/index.css @@ -0,0 +1,470 @@ +/* ── Marriage tab UI ─────────────────────────────────────────────────────── */ + +/* + * Marriage page — romantic / warm theme + * + * Palette — cherry blossom / dusty rose (high-lightness, low-saturation pinks): + * rose-dust hsl(347 50% 80%) → rgb(232 175 188) tabs, borders + * blush hsl(345 55% 89%) → rgb(244 212 220) text, light accents + * peach-warm hsl(20 45% 83%) → rgb(234 203 185) second partner warmth + * heart-pink hsl(345 45% 75%) → rgb(219 157 172) heart colour + * + * None of these are anywhere near blood-red — they are soft pastels that read + * as cherry blossom / strawberry cream against the dark background. + */ + +.marriage-interface { + display: flex; + flex-direction: column; + gap: 0; + background: linear-gradient(160deg, rgb(232 175 188 / 4%) 0%, transparent 55%); + border-radius: 0 0 10px 10px; +} + +.marriage-tabs { + display: flex; + gap: 4px; + margin-bottom: 0; + border-bottom: 1px solid rgb(232 175 188 / 20%); + padding-bottom: 0; +} + +.marriage-tab { + flex: 1; + padding: 10px 16px; + background: transparent; + border: 1px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + color: rgb(232 175 188 / 55%); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(.active) { + color: rgb(232 175 188 / 88%); + background: rgb(232 175 188 / 6%); + } + + &.active { + color: #f0d5de; + background: rgb(232 175 188 / 9%); + border-color: rgb(232 175 188 / 26%); + border-bottom-color: rgb(5 13 30 / 92%); + margin-bottom: -1px; + } +} + +.marriage-tab-panel { + padding-top: 20px; +} + +.marriage-tab-hint { + font-size: 13px; + color: rgb(232 175 188 / 58%); + margin: 0 0 16px !important; + text-align: left !important; +} + +.marriage-status-section { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(232 175 188 / 16%); + display: flex; + flex-direction: column; + gap: 10px; +} + +.marriage-status-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: rgb(232 175 188 / 60%); +} + +/* ── Action buttons ───────────────────────────────────────────────────── */ + +.marriage-row-action { + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &.divorce { + background: rgb(255 80 80 / 10%); + border: 1px solid rgb(255 80 80 / 32%); + color: #ff9a9a; + + &:hover:not(:disabled) { + background: rgb(255 80 80 / 20%); + border-color: rgb(255 80 80 / 55%); + } + } + + &.cancel { + background: rgb(255 181 67 / 10%); + border: 1px solid rgb(255 181 67 / 36%); + color: #ffd07b; + + &:hover:not(:disabled) { + background: rgb(255 181 67 / 20%); + border-color: rgb(255 181 67 / 60%); + } + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.propose-button { + color: #ffd07b; + border-color: rgb(255 181 67 / 50%); + box-shadow: inset 0 0 14px rgb(255 181 67 / 16%), 0 0 14px rgb(255 181 67 / 22%); + grid-column: 1 / -1; + + &:hover:not(:disabled) { + transform: translateY(-2px); + border-color: rgb(255 181 67 / 70%); + box-shadow: inset 0 0 20px rgb(255 181 67 / 24%), 0 0 22px rgb(255 181 67 / 38%); + } +} + +.accept-button { + color: #9effd4; + border-color: rgb(0 255 157 / 48%); + box-shadow: inset 0 0 14px rgb(0 255 157 / 14%), 0 0 14px rgb(0 255 157 / 22%); + grid-column: 1 / -1; + + &:hover:not(:disabled) { + transform: translateY(-2px); + border-color: rgb(0 255 157 / 64%); + box-shadow: inset 0 0 20px rgb(0 255 157 / 22%), 0 0 22px rgb(0 255 157 / 36%); + } +} + +/* ── Marriage list & cards ────────────────────────────────────────────── */ + +.marriage-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; + + &:empty::after { + content: 'No married pets yet. Send a proposal to find a match! 💕'; + display: block; + padding: 14px 12px; + font-size: 12px; + color: rgb(232 175 188 / 38%); + font-style: italic; + text-align: center; + } +} + +/* Romantic two-pet marriage card — cherry blossom palette */ +.marriage-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px solid rgb(232 175 188 / 28%); + border-radius: 12px; + background: linear-gradient(135deg, rgb(244 212 220 / 7%) 0%, rgb(234 203 185 / 5%) 100%); + box-shadow: 0 0 20px rgb(219 157 172 / 8%), inset 0 1px 0 rgb(244 212 220 / 10%); + transition: box-shadow 0.3s ease, border-color 0.3s ease; + + &:hover { + border-color: rgb(232 175 188 / 44%); + box-shadow: 0 0 28px rgb(219 157 172 / 14%), inset 0 1px 0 rgb(244 212 220 / 14%); + } +} + +.marriage-pair { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + min-width: 0; +} + +.marriage-partner { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.partner-name { + font-size: 14px; + font-weight: 700; + color: #f0d5de; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.partner-meta { + font-size: 11px; + color: rgb(232 175 188 / 48%); +} + +/* Heart — soft candy pink, gentle pulse, no vivid red */ +.marriage-heart { + font-size: 20px; + color: #e8a0b4; + text-shadow: 0 0 10px rgb(219 157 172 / 55%); + flex-shrink: 0; + animation: marriage-heartbeat 2.2s ease-in-out infinite; +} + +@keyframes marriage-heartbeat { + 0%, + 100% { + transform: scale(1); + opacity: 0.85; + } + 35% { + transform: scale(1.2); + opacity: 1; + } + 50% { + transform: scale(1.08); + opacity: 0.95; + } +} + +/* Legacy row class kept for any stray usage */ +.marriage-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 1px solid rgb(232 175 188 / 20%); + border-radius: 8px; + background: rgb(5 13 30 / 50%); +} + +.marriage-row .marriage-pet { + font-weight: 700; +} + +.marriage-row .marriage-status { + margin-left: auto; + opacity: 0.85; + font-size: var(--font-size-xs); +} + +/* Propose tab — sent proposals section */ +.sent-proposals-section { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid rgb(125 214 255 / 14%); + display: flex; + flex-direction: column; + gap: 8px; +} + +.sent-proposals-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: rgb(195 210 255 / 50%); +} + +/* Outgoing proposal card variant (amber tint vs green for incoming) */ +.outgoing-proposal { + border-color: rgb(255 181 67 / 22%) !important; + background: rgb(255 181 67 / 4%) !important; +} + +/* Empty state when all OutgoingProposalRow components return null */ +.sent-proposals-section .proposals-list:empty::after { + content: 'No pending proposals sent.'; + display: block; + padding: 10px 12px; + font-size: 12px; + color: rgb(125 214 255 / 35%); + font-style: italic; + text-align: center; +} + +/* Accept tab — tab badge (unread count) */ +.marriage-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 4px; + margin-left: 6px; + border-radius: 9px; + background: rgb(0 255 157 / 22%); + border: 1px solid rgb(0 255 157 / 48%); + color: #9effd4; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +/* Accept tab — empty state */ +.proposals-empty { + padding: 20px 12px; + font-size: 13px; + color: rgb(125 214 255 / 40%); + font-style: italic; + text-align: center; +} + +/* Accept tab — proposals list */ +.proposals-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.proposal-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border: 1px solid rgb(0 255 157 / 22%); + border-radius: 8px; + background: rgb(0 255 157 / 4%); +} + +.proposal-pets { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + flex-wrap: wrap; +} + +.proposal-proposer { + color: rgb(195 210 255 / 90%); +} + +.proposal-arrow { + color: rgb(0 255 157 / 60%); +} + +.proposal-target { + color: #9effd4; +} + +.proposal-id { + font-weight: 400; + font-size: 12px; + opacity: 0.7; +} + +.proposal-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.proposal-expiry { + font-size: 11px; + color: rgb(125 214 255 / 45%); +} + +.accept-inline { + background: rgb(0 255 157 / 10%); + border: 1px solid rgb(0 255 157 / 36%); + color: #9effd4; + padding: 4px 12px; + font-size: 12px; + + &:hover:not(:disabled) { + background: rgb(0 255 157 / 20%); + border-color: rgb(0 255 157 / 60%); + } +} + +/* Confirm accept modal */ +.marriage-confirm-overlay { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 55%); + display: flex; + align-items: center; + justify-content: center; + z-index: 8000; + padding: 16px; +} + +.marriage-confirm-dialog { + background: rgb(5 13 30 / 97%); + border: 1px solid rgb(0 255 157 / 36%); + border-radius: 12px; + padding: 24px; + max-width: 380px; + width: 100%; + box-shadow: 0 0 40px rgb(0 255 157 / 14%); + display: flex; + flex-direction: column; + gap: 16px; +} + +.confirm-title { + margin: 0; + font-size: 16px; + color: #9effd4; +} + +.confirm-body { + margin: 0; + font-size: 14px; + color: rgb(195 210 255 / 80%); + line-height: 1.5; +} + +.confirm-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.confirm-cancel { + padding: 8px 18px; + border-radius: 6px; + background: transparent; + border: 1px solid rgb(125 214 255 / 25%); + color: rgb(195 210 255 / 70%); + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: rgb(125 214 255 / 8%); + border-color: rgb(125 214 255 / 50%); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } +} + +.confirm-accept { + padding: 8px 20px !important; + font-size: 13px !important; +} diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.tsx b/frontend/src/components/pet/interactions/panels/marriage/index.tsx index 39a4a8f7..2e4790cc 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/index.tsx @@ -18,6 +18,7 @@ import AcceptTab from './parts/accept-tab'; import ActiveMarriages from './parts/active-marriages'; import AcceptConfirmDialog from './parts/accept-confirm-dialog'; import type { MarriagePanelProps, MarriageTab, PendingAccept } from './types'; +import './index.css'; const MarriagePanel: React.FC = ({ isStandaloneView = true }) => { const { kind, activeKind, walletAddress } = useChainCapabilities(); diff --git a/frontend/src/components/pet/interactions/standalone/index.tsx b/frontend/src/components/pet/interactions/standalone/index.tsx index 346cfe79..562ea8eb 100644 --- a/frontend/src/components/pet/interactions/standalone/index.tsx +++ b/frontend/src/components/pet/interactions/standalone/index.tsx @@ -7,7 +7,7 @@ import { Tones } from '@constants/tones'; import Icon, { BattleIcon } from '@components/ui/icon'; import DashboardPanel from '@components/common/dashboard-panel'; import StateCard from '@components/pet/interactions/state-card'; -import '@components/pet/interactions/overview/index.css'; +import '@components/pet/interactions/interactions.css'; export type InteractionStandaloneProps = { action: InteractionAction; From 20bbb9688d1d0ecb2870a67848a4abc3fe9c28ab Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 17:46:31 -0400 Subject: [PATCH 37/48] refactor: extract shared useSpousePet hook --- frontend/CODE_QUALITY_REFACTOR.md | 37 ++++++++++-- .../panels/marriage/parts/marriage-card.tsx | 29 +-------- .../components/pet/panels/marriage.test.tsx | 1 + shared/src/hooks/index.ts | 5 ++ shared/src/hooks/useSpousePet.ts | 60 +++++++++++++++++++ 5 files changed, 101 insertions(+), 31 deletions(-) create mode 100644 shared/src/hooks/useSpousePet.ts diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index b00cf411..134c0613 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -37,7 +37,7 @@ session can resume without re-deriving context. |---|------|--------| | 1 | Tooling: Prettier + `.editorconfig` + reformat | DONE | | 2 | Colocate panel CSS (split `overview/index.css`) | DONE | -| 3 | Extract shared `useSpousePet` hook | TODO | +| 3 | Extract shared `useSpousePet` hook | DONE | | 4 | Decompose `breed` panel into orchestrator + parts | TODO | | 5 | Extract `pet-gallery` cooldown logic into a hook | TODO | | 6 | Design-system decision + button consolidation | TODO | @@ -181,7 +181,7 @@ Co-Authored-By: Claude Opus 4.8 --- ## Step 3 — Extract shared `useSpousePet` hook -**Status:** TODO +**Status:** DONE **Goal:** One spouse-name-by-id lookup hook, reused by marriage and breed. @@ -202,9 +202,37 @@ Both run `useQuery(['pet', baseURL, chain, id])`. - Marriage cards still resolve spouse name/level. - No behavior change; typecheck + lint + marriage tests pass. -**Outcome:** _(fill after completion)_ +**Outcome (done):** +- **NEW** `shared/src/hooks/useSpousePet.ts` — `useSpousePet(chain, id, { skip? })` returning + `{ name?, level? }`. Superset of both old copies (selects `id name level`); keeps the + `['pet', baseURL, chain, id]` query key so marriage + breed dedupe. Throws on GraphQL errors + (the marriage copy silently swallowed them — minor improvement). Exported via + `shared/src/hooks/index.ts` → `@shared/core`. +- `marriage/parts/marriage-card.tsx` now imports `useSpousePet` from `@shared/core`; removed the + local hook + `SPOUSE_GQL` + the now-unused `useQuery`/`useApiClient` imports. Call site updated + to the options form: `useSpousePet(chain, spouseId, { skip: Boolean(fromMap) })`. +- Test: added `useSpousePet` to the `@shared/core` mock in `marriage.test.tsx` (the hook is + called unconditionally in `MarriageCard` before its married-check early return). +- **Breed still has its own `SpouseLabel`** — it gets swapped to this hook in Step 4 (kept here + to keep the diff focused on landing the hook + marriage swap). +- **Verified:** `format:check` clean; `tsc -b` (builds shared + frontend) 0; `eslint .` 0; + **272/272 tests pass**. +- **Scope note:** this commit spans both `shared/` and `frontend/`. -**Commit message:** _(fill after completion)_ +**Commit message:** +``` +refactor: extract shared useSpousePet hook + +Move the spouse-name-by-id GraphQL lookup duplicated in the marriage and +breed panels into a single useSpousePet hook in @shared/core. It returns +{ name, level }, shares the ['pet', baseURL, chain, id] query key so callers +dedupe, and surfaces GraphQL errors. Marriage now consumes the shared hook; +breed is migrated in a follow-up. + +Typecheck, lint, and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` --- @@ -338,3 +366,4 @@ behavior preserved; typecheck + lint + tests pass. ## Change log - 2026-06-22 — Step 1 — chore(frontend): add prettier + editorconfig and reformat src - 2026-06-22 — Step 2 — refactor(frontend): colocate interaction panel CSS +- 2026-06-22 — Step 3 — refactor: extract shared useSpousePet hook diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx index eeec5706..1897d2d9 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx @@ -1,38 +1,13 @@ import React from 'react'; -import { useQuery } from '@tanstack/react-query'; import { - useApiClient, useMarriageInfo, + useSpousePet, type OpponentPet, type Pet, type PetChain, } from '@shared/core'; import { AuthActionButton } from '@components/common'; -const SPOUSE_GQL = `query SpousePet($chain:String!,$id:String!){pet(chain:$chain,id:$id){id name level}}`; - -/** Direct no-debounce pet lookup by ID — fires immediately on mount. */ -const useSpousePet = ( - chain: PetChain | null, - spouseId: string, - skip: boolean, -): { name?: string; level?: number } => { - const apiClient = useApiClient(); - const baseURL = apiClient.defaults.baseURL ?? ''; - const { data } = useQuery({ - queryKey: ['pet', baseURL, chain, spouseId], - enabled: !skip && Boolean(chain && spouseId && spouseId !== '0'), - queryFn: async () => { - const res = await apiClient.post<{ - data?: { pet: { id: string; name: string; level: number } | null }; - }>('/graphql', { query: SPOUSE_GQL, variables: { chain, id: spouseId } }); - return res.data.data?.pet ?? null; - }, - staleTime: 60_000, - }); - return { name: data?.name, level: data?.level }; -}; - type MarriageCardProps = { pet: Pet; chain: PetChain | null; @@ -51,7 +26,7 @@ const MarriageCard: React.FC = ({ pet, chain, petById, onDivo // Direct no-debounce fallback: single pet(chain, id) query fires immediately // when the bulk allPets map doesn't have this pet yet. - const fetched = useSpousePet(chain, spouseId, Boolean(fromMap)); + const fetched = useSpousePet(chain, spouseId, { skip: Boolean(fromMap) }); if (!info.isMarried || !spouseId) return null; diff --git a/frontend/tests/components/pet/panels/marriage.test.tsx b/frontend/tests/components/pet/panels/marriage.test.tsx index 9ee797d6..d9e5df1a 100644 --- a/frontend/tests/components/pet/panels/marriage.test.tsx +++ b/frontend/tests/components/pet/panels/marriage.test.tsx @@ -60,6 +60,7 @@ vi.mock('@shared/core', () => ({ useMarriage: () => marriage, useMarriageInfo: () => marriageInfo, useApiClient: () => ({ defaults: { baseURL: '' }, post: vi.fn() }), + useSpousePet: () => ({ name: undefined, level: undefined }), })); import MarriagePanel from '@components/pet/interactions/panels/marriage'; diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 9560206c..b8a3f282 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -50,6 +50,11 @@ export { useTransferPet, type TransferPetArgs } from './useTransferPet'; export { useOpponents, type UseOpponentsOptions } from './useOpponents'; export { useSearchPets, type UseSearchPetsOptions, type SearchPetsResult } from './useSearchPets'; export { useAllPets, type UseAllPetsOptions } from './useAllPets'; +export { + useSpousePet, + type UseSpousePetOptions, + type SpousePetResult, +} from './useSpousePet'; export { useIncomingProposals, type IncomingProposal } from './useIncomingProposals'; export { useWinEstimate, type WinEstimateResult } from './useWinEstimate'; export { diff --git a/shared/src/hooks/useSpousePet.ts b/shared/src/hooks/useSpousePet.ts new file mode 100644 index 00000000..270b9b85 --- /dev/null +++ b/shared/src/hooks/useSpousePet.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import { useApiClient } from '../contexts/ApiClientContext'; +import type { PetChain } from '../types/pet'; + +const SPOUSE_PET_QUERY = ` + query SpousePet($chain: String!, $id: String!) { + pet(chain: $chain, id: $id) { id name level } + } +`; + +interface SpousePetDto { + id: string; + name: string; + level: number; +} + +interface GraphQLResponse { + data?: { pet: SpousePetDto | null }; + errors?: { message: string }[]; +} + +export interface UseSpousePetOptions { + /** Skip the query — e.g. the pet is already resolved from a bulk roster fetch. */ + skip?: boolean; +} + +export interface SpousePetResult { + name?: string; + level?: number; +} + +/** + * Direct pet-by-id lookup (no debounce) — fires immediately on mount to resolve a + * spouse's name/level when the bulk roster doesn't already have it. Shares the + * `['pet', baseURL, chain, id]` query key so multiple callers dedupe the request. + */ +export const useSpousePet = ( + chain: PetChain | null, + id: string, + { skip = false }: UseSpousePetOptions = {}, +): SpousePetResult => { + const apiClient = useApiClient(); + const baseURL = apiClient.defaults.baseURL ?? ''; + const { data } = useQuery({ + queryKey: ['pet', baseURL, chain, id], + enabled: !skip && Boolean(chain && id && id !== '0'), + queryFn: async () => { + const res = await apiClient.post('/graphql', { + query: SPOUSE_PET_QUERY, + variables: { chain, id }, + }); + if (res.data.errors?.length) { + throw new Error(res.data.errors.map((e) => e.message).join('; ')); + } + return res.data.data?.pet ?? null; + }, + staleTime: 60_000, + }); + return { name: data?.name, level: data?.level }; +}; From d38654be3c351b5ad28dae7197503955a3e64a19 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 18:04:26 -0400 Subject: [PATCH 38/48] refactor: decompose breed panel into parts --- frontend/CODE_QUALITY_REFACTOR.md | 42 ++- .../pet/interactions/panels/breed/index.tsx | 249 +++--------------- .../panels/breed/parts/breed-tab-bar.tsx | 29 ++ .../panels/breed/parts/own-pets-tab.tsx | 99 +++++++ .../{ => parts}/pending-breed-notice.tsx | 0 .../panels/breed/parts/spouse-label.tsx | 15 ++ .../breed/{ => parts}/stud-fee-balance.tsx | 0 .../panels/breed/parts/with-spouse-tab.tsx | 115 ++++++++ .../pet/interactions/panels/breed/types.ts | 6 + .../components/pet/panels/breed.test.tsx | 57 +++- 10 files changed, 380 insertions(+), 232 deletions(-) create mode 100644 frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx create mode 100644 frontend/src/components/pet/interactions/panels/breed/parts/own-pets-tab.tsx rename frontend/src/components/pet/interactions/panels/breed/{ => parts}/pending-breed-notice.tsx (100%) create mode 100644 frontend/src/components/pet/interactions/panels/breed/parts/spouse-label.tsx rename frontend/src/components/pet/interactions/panels/breed/{ => parts}/stud-fee-balance.tsx (100%) create mode 100644 frontend/src/components/pet/interactions/panels/breed/parts/with-spouse-tab.tsx create mode 100644 frontend/src/components/pet/interactions/panels/breed/types.ts diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 134c0613..300a7678 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -38,7 +38,7 @@ session can resume without re-deriving context. | 1 | Tooling: Prettier + `.editorconfig` + reformat | DONE | | 2 | Colocate panel CSS (split `overview/index.css`) | DONE | | 3 | Extract shared `useSpousePet` hook | DONE | -| 4 | Decompose `breed` panel into orchestrator + parts | TODO | +| 4 | Decompose `breed` panel into orchestrator + parts | DONE | | 5 | Extract `pet-gallery` cooldown logic into a hook | TODO | | 6 | Design-system decision + button consolidation | TODO | | 7 | Shared accessible modal + migrate bespoke modals | TODO | @@ -237,7 +237,7 @@ Co-Authored-By: Claude Opus 4.8 --- ## Step 4 — Decompose `breed` panel into orchestrator + parts -**Status:** TODO +**Status:** DONE **Goal:** Reduce `panels/breed/index.tsx` (~403 lines) to a slim orchestrator plus `parts/`, mirroring the marriage refactor. @@ -259,9 +259,42 @@ relative-detection logic, pending-breed logic, and all JSX in one file. and submit all behave identically. Verify in-app. - Typecheck + lint + any breed tests pass. -**Outcome:** _(fill after completion)_ +**Outcome (done):** +- `breed/index.tsx`: **466 → 280 lines**. Now a slim orchestrator owning state, effects, + contract reads (relative detection), pending-breed checks, and the mutation; delegates all + rendering. +- `types.ts`: `BreedTab`, `BreedPanelProps`. +- New `parts/`: `breed-tab-bar.tsx`, `own-pets-tab.tsx`, `with-spouse-tab.tsx`, + `spouse-label.tsx` (now uses the shared `useSpousePet` from Step 3 — the old local + `SpouseLabel` + `SPOUSE_NAME_GQL` are gone). +- Moved the two existing flat sub-files into `parts/` (git mv): `pending-breed-notice.tsx`, + `stud-fee-balance.tsx` — breed now matches the battle/marriage `parts/` convention. +- Tab-local state stays in the orchestrator (it feeds the relative-check contract reads, + `canSubmit`, and the success reset), so the tabs are controlled via props — same as the + marriage tabs. +- Tests: updated `breed.test.tsx` mock paths to `parts/...` and added `useSpousePet` to the + `@shared/core` mock (returns no name so `SpouseLabel` falls back to `#id`). +- **Verified:** `format:check` clean; `tsc -b` 0; `eslint .` 0; breed tests 11/11; + **272/272 tests pass**. +- **Optional future refinement:** index.tsx is still ~280 lines because all logic lives there + (intentional, matches marriage). If a thinner view is wanted later, extract a headless + `useBreedPanel` hook (battle-style) — deliberately deferred to keep this diff focused/low-risk. -**Commit message:** _(fill after completion)_ +**Commit message:** +``` +refactor(frontend): decompose breed panel into parts + +Split the 466-line breed/index.tsx into a slim orchestrator plus parts +(tab bar, own-pets tab, with-spouse tab, spouse label) and move the existing +pending-breed-notice / stud-fee-balance into parts/, matching the +battle/marriage convention. The spouse label now uses the shared useSpousePet +hook, removing the last duplicated spouse-name GraphQL lookup. + +Behavior preserved; breed tests updated for the new module paths. Typecheck, +lint, and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` --- @@ -367,3 +400,4 @@ behavior preserved; typecheck + lint + tests pass. - 2026-06-22 — Step 1 — chore(frontend): add prettier + editorconfig and reformat src - 2026-06-22 — Step 2 — refactor(frontend): colocate interaction panel CSS - 2026-06-22 — Step 3 — refactor: extract shared useSpousePet hook +- 2026-06-22 — Step 4 — refactor(frontend): decompose breed panel into parts diff --git a/frontend/src/components/pet/interactions/panels/breed/index.tsx b/frontend/src/components/pet/interactions/panels/breed/index.tsx index 01b30517..600e43da 100644 --- a/frontend/src/components/pet/interactions/panels/breed/index.tsx +++ b/frontend/src/components/pet/interactions/panels/breed/index.tsx @@ -1,9 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { useReadContracts } from 'wagmi'; import TransactionStatus from '@components/common/transaction-status'; import { - useApiClient, useChainCapabilities, useBreedPets, useFees, @@ -11,51 +9,22 @@ import { usePendingBreed, usePetsConfig, usePetList, - type PetChain, } from '@shared/core'; import { Tones } from '@constants/tones'; import { AuthActionButton } from '@components/common'; import { formatTxHashHint } from '@hooks/usePetError'; import { usePetErrorToast } from '@hooks/usePetErrorToast'; -import PendingBreedNotice from './pending-breed-notice'; -import StudFeeBalance from './stud-fee-balance'; import Icon, { CheckIcon, DnaIcon } from '@components/ui/icon'; +import BreedTabBar from './parts/breed-tab-bar'; +import OwnPetsTab from './parts/own-pets-tab'; +import WithSpouseTab from './parts/with-spouse-tab'; +import StudFeeBalance from './parts/stud-fee-balance'; +import type { BreedPanelProps, BreedTab } from './types'; import './index.css'; -export type BreedPanelProps = { - /** `false` when embedded under the dashboard interactions hub. */ - isStandaloneView?: boolean; -}; - const BREED_FAIL_MESSAGE = 'Failed to breed pets. Please try again.'; const AWAITING_HINT = 'Hang tight—your new pet will show up in a moment.'; -type BreedTab = 'own' | 'spouse'; - -const SPOUSE_NAME_GQL = `query($chain:String!,$id:String!){pet(chain:$chain,id:$id){name}}`; - -/** Fetches spouse pet name immediately (no debounce) — shows ID as fallback. */ -const SpouseLabel: React.FC<{ chain: PetChain | null; spouseId: string }> = ({ - chain, - spouseId, -}) => { - const apiClient = useApiClient(); - const baseURL = apiClient.defaults.baseURL ?? ''; - const { data } = useQuery({ - queryKey: ['pet', baseURL, chain, spouseId], - enabled: Boolean(chain && spouseId && spouseId !== '0'), - queryFn: async () => { - const res = await apiClient.post<{ data?: { pet: { name: string } | null } }>( - '/graphql', - { query: SPOUSE_NAME_GQL, variables: { chain, id: spouseId } }, - ); - return res.data.data?.pet?.name ?? null; - }, - staleTime: 60_000, - }); - return <>{data ? `${data} (#${spouseId})` : `#${spouseId}`}; -}; - const BreedPanel: React.FC = ({ isStandaloneView = true }) => { const { randomness, activeKind, kind } = useChainCapabilities(); const { evm } = usePetsConfig(); @@ -241,192 +210,38 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { )} - {/* Tab bar */} -
    - - -
    + - {/* ── My Pets tab ──────────────────────────────────────────────── */} {tab === 'own' && ( -
    - {pets.length < 2 ? ( -
    -

    You need at least 2 pets to breed here.

    -

    - Use the With Spouse tab if your pet is married. -

    -
    - ) : ( - <> -

    - Select two of your pets to breed together. -

    -
    -
    - - -
    -
    - - -
    -
    - {areRelated && ( -

    - These pets are relatives and cannot breed together. -

    - )} - {!breed.isAwaitingFulfillment && ( - <> - - - - )} -
    - - setOwnChildName(e.target.value)} - placeholder="Name for the new pet…" - maxLength={20} - /> -
    - - )} -
    + )} - {/* ── With Spouse tab ──────────────────────────────────────────── */} {tab === 'spouse' && ( -
    -

    - Select one of your pets to breed with their spouse. -

    -
    -
    - - -
    -
    - -
    - {!spousePetId ? ( - - — select your pet first — - - ) : marriageInfo.isLoading ? ( - Checking… - ) : spouseId ? ( - - ) : ( - Not married - )} -
    -
    -
    - - {/* Not-married hint */} - {spousePetId && !marriageInfo.isLoading && !marriageInfo.isMarried && ( -
    -

    This pet is not married yet.

    -

    - Go to the Marriage page to propose first. -

    -
    - )} - - {/* Married — show stud fee + breed inputs */} - {spouseId && ( - <> - {studFeeLabel && ( -
    - Stud fee: {studFeeLabel} — paid to the - spouse owner. -
    - )} - {areRelated && ( -

    - Your pet and their spouse are relatives and cannot breed - together. -

    - )} - {/* Only show recovery notice for the user's own pet. - The spouse's pet also has a pending flag while the breed - is in-flight, but the user can't settle/cancel it and - showing those buttons there is confusing. */} - {!breed.isAwaitingFulfillment && ( - - )} -
    - - setSpouseChildName(e.target.value)} - placeholder="Name for the new pet…" - maxLength={20} - /> -
    - - )} -
    + )} diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx new file mode 100644 index 00000000..9a3188e5 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/breed-tab-bar.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { BreedTab } from '../types'; + +type BreedTabBarProps = { + tab: BreedTab; + onChange: (tab: BreedTab) => void; +}; + +/** My Pets / With Spouse switcher. */ +const BreedTabBar: React.FC = ({ tab, onChange }) => ( +
    + + +
    +); + +export default BreedTabBar; diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/own-pets-tab.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/own-pets-tab.tsx new file mode 100644 index 00000000..faf888e3 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/own-pets-tab.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import type { Pet } from '@shared/core'; +import PendingBreedNotice from './pending-breed-notice'; + +type OwnPetsTabProps = { + petCount: number; + allPets: { id: string; pet: Pet }[]; + pet1: string; + pet2: string; + childName: string; + onPet1Change: (id: string) => void; + onPet2Change: (id: string) => void; + onChildNameChange: (name: string) => void; + areRelated: boolean; + /** Hide the pending-breed recovery notices while a breed is settling. */ + showPendingNotices: boolean; +}; + +/** Breed two of the user's own pets together. */ +const OwnPetsTab: React.FC = ({ + petCount, + allPets, + pet1, + pet2, + childName, + onPet1Change, + onPet2Change, + onChildNameChange, + areRelated, + showPendingNotices, +}) => { + if (petCount < 2) { + return ( +
    +
    +

    You need at least 2 pets to breed here.

    +

    + Use the With Spouse tab if your pet is married. +

    +
    +
    + ); + } + + return ( +
    +

    Select two of your pets to breed together.

    +
    +
    + + +
    +
    + + +
    +
    + {areRelated && ( +

    + These pets are relatives and cannot breed together. +

    + )} + {showPendingNotices && ( + <> + + + + )} +
    + + onChildNameChange(e.target.value)} + placeholder="Name for the new pet…" + maxLength={20} + /> +
    +
    + ); +}; + +export default OwnPetsTab; diff --git a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/pending-breed-notice.tsx similarity index 100% rename from frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx rename to frontend/src/components/pet/interactions/panels/breed/parts/pending-breed-notice.tsx diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/spouse-label.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/spouse-label.tsx new file mode 100644 index 00000000..62b5c03f --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/spouse-label.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useSpousePet, type PetChain } from '@shared/core'; + +type SpouseLabelProps = { + chain: PetChain | null; + spouseId: string; +}; + +/** Resolves a spouse pet's name (no debounce); falls back to its id. */ +const SpouseLabel: React.FC = ({ chain, spouseId }) => { + const { name } = useSpousePet(chain, spouseId); + return <>{name ? `${name} (#${spouseId})` : `#${spouseId}`}; +}; + +export default SpouseLabel; diff --git a/frontend/src/components/pet/interactions/panels/breed/stud-fee-balance.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/stud-fee-balance.tsx similarity index 100% rename from frontend/src/components/pet/interactions/panels/breed/stud-fee-balance.tsx rename to frontend/src/components/pet/interactions/panels/breed/parts/stud-fee-balance.tsx diff --git a/frontend/src/components/pet/interactions/panels/breed/parts/with-spouse-tab.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/with-spouse-tab.tsx new file mode 100644 index 00000000..06e9e756 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/with-spouse-tab.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import type { Pet, PetChain } from '@shared/core'; +import PendingBreedNotice from './pending-breed-notice'; +import SpouseLabel from './spouse-label'; + +type WithSpouseTabProps = { + allPets: { id: string; pet: Pet }[]; + chain: PetChain | null; + spousePetId: string; + onSpousePetChange: (id: string) => void; + childName: string; + onChildNameChange: (name: string) => void; + marriageLoading: boolean; + isMarried: boolean; + spouseId?: string; + studFeeLabel: string | null; + areRelated: boolean; + /** Hide the pending-breed recovery notice while a breed is settling. */ + showPendingNotices: boolean; +}; + +/** Breed one of the user's pets with its married partner (cross-owner). */ +const WithSpouseTab: React.FC = ({ + allPets, + chain, + spousePetId, + onSpousePetChange, + childName, + onChildNameChange, + marriageLoading, + isMarried, + spouseId, + studFeeLabel, + areRelated, + showPendingNotices, +}) => ( +
    +

    Select one of your pets to breed with their spouse.

    +
    +
    + + +
    +
    + +
    + {!spousePetId ? ( + — select your pet first — + ) : marriageLoading ? ( + Checking… + ) : spouseId ? ( + + ) : ( + Not married + )} +
    +
    +
    + + {/* Not-married hint */} + {spousePetId && !marriageLoading && !isMarried && ( +
    +

    This pet is not married yet.

    +

    + Go to the Marriage page to propose first. +

    +
    + )} + + {/* Married — show stud fee + breed inputs */} + {spouseId && ( + <> + {studFeeLabel && ( +
    + Stud fee: {studFeeLabel} — paid to the spouse owner. +
    + )} + {areRelated && ( +

    + Your pet and their spouse are relatives and cannot breed together. +

    + )} + {/* Only show recovery notice for the user's own pet. + The spouse's pet also has a pending flag while the breed + is in-flight, but the user can't settle/cancel it and + showing those buttons there is confusing. */} + {showPendingNotices && ( + + )} +
    + + onChildNameChange(e.target.value)} + placeholder="Name for the new pet…" + maxLength={20} + /> +
    + + )} +
    +); + +export default WithSpouseTab; diff --git a/frontend/src/components/pet/interactions/panels/breed/types.ts b/frontend/src/components/pet/interactions/panels/breed/types.ts new file mode 100644 index 00000000..c67732c1 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/types.ts @@ -0,0 +1,6 @@ +export type BreedTab = 'own' | 'spouse'; + +export type BreedPanelProps = { + /** `false` when embedded under the dashboard interactions hub. */ + isStandaloneView?: boolean; +}; diff --git a/frontend/tests/components/pet/panels/breed.test.tsx b/frontend/tests/components/pet/panels/breed.test.tsx index 3688688d..8edbf611 100644 --- a/frontend/tests/components/pet/panels/breed.test.tsx +++ b/frontend/tests/components/pet/panels/breed.test.tsx @@ -8,7 +8,15 @@ vi.mock('wagmi', () => ({ useReadContracts: () => ({ data: undefined }) })); vi.mock('@constants/interactionRoutes', () => ({ DASHBOARD_HOME: '/dashboard' })); vi.mock('@hooks/usePetErrorToast', () => ({ usePetErrorToast: vi.fn() })); vi.mock('@components/common', () => ({ - AuthActionButton: ({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: React.ReactNode }) => ( + AuthActionButton: ({ + onClick, + disabled, + children, + }: { + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; + }) => ( @@ -32,13 +40,19 @@ const DEFAULT_PETS = [ { id: '2', name: 'Beta', level: 5 }, ]; const petList = { - pets: [...DEFAULT_PETS] as Array<{ id: string; name: string; level: number; spouseId?: number }>, + pets: [...DEFAULT_PETS] as Array<{ + id: string; + name: string; + level: number; + spouseId?: number; + }>, refetch: vi.fn(), }; const capabilities = { randomness: { provider: 'vrf' }, kind: 'solana' }; vi.mock('@shared/core', () => ({ - getReadyPetsUnified: (pets: { id: string; level: number }[]) => pets.map((p) => ({ id: p.id, pet: p })), + getReadyPetsUnified: (pets: { id: string; level: number }[]) => + pets.map((p) => ({ id: p.id, pet: p })), useChainCapabilities: () => capabilities, usePetList: () => petList, useFees: () => ({ @@ -48,10 +62,23 @@ vi.mock('@shared/core', () => ({ formatAmountOnly: (v: bigint) => String(v), }), useApiClient: () => ({ defaults: { baseURL: '' }, post: vi.fn() }), + useSpousePet: () => ({ name: undefined, level: undefined }), useMarriageInfo: (pet?: { spouseId?: number }) => pet?.spouseId - ? { isMarried: true, spouseId: BigInt(pet.spouseId), isLoading: false, hasProposal: false, refetch: vi.fn() } - : { isMarried: false, spouseId: undefined, isLoading: false, hasProposal: false, refetch: vi.fn() }, + ? { + isMarried: true, + spouseId: BigInt(pet.spouseId), + isLoading: false, + hasProposal: false, + refetch: vi.fn(), + } + : { + isMarried: false, + spouseId: undefined, + isLoading: false, + hasProposal: false, + refetch: vi.fn(), + }, usePendingBreed: () => ({ isPending: false }), usePetsConfig: () => ({ evm: undefined }), useBreedPets: (opts: { onSuccess?: (arg: { name: string }) => void }) => { @@ -64,10 +91,10 @@ vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: undefined, isLoading: false, error: null }), useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); -vi.mock('@components/pet/interactions/panels/breed/pending-breed-notice', () => ({ +vi.mock('@components/pet/interactions/panels/breed/parts/pending-breed-notice', () => ({ default: () => null, })); -vi.mock('@components/pet/interactions/panels/breed/stud-fee-balance', () => ({ +vi.mock('@components/pet/interactions/panels/breed/parts/stud-fee-balance', () => ({ default: () => null, })); @@ -96,7 +123,9 @@ describe('BreedPanel — My Pets tab', () => { await userEvent.selectOptions(first, '1'); - expect(within(second).queryByRole('option', { name: 'Alpha (Lv 2)' })).not.toBeInTheDocument(); + expect( + within(second).queryByRole('option', { name: 'Alpha (Lv 2)' }), + ).not.toBeInTheDocument(); expect(within(second).getByRole('option', { name: 'Beta (Lv 5)' })).toBeInTheDocument(); }); @@ -130,7 +159,9 @@ describe('BreedPanel — My Pets tab', () => { render(); expect(screen.getByRole('button', { name: 'Creating…' })).toBeInTheDocument(); - expect(screen.getByText('Hang tight—your new pet will show up in a moment.')).toBeInTheDocument(); + expect( + screen.getByText('Hang tight—your new pet will show up in a moment.'), + ).toBeInTheDocument(); }); it('uses Switchboard VRF labels and shows a tx hash hint', () => { @@ -145,7 +176,9 @@ describe('BreedPanel — My Pets tab', () => { it('resets when breed.reset is called', () => { render(); - act(() => { breed.reset(); }); + act(() => { + breed.reset(); + }); expect(breed.reset).toBeDefined(); }); }); @@ -161,7 +194,9 @@ describe('BreedPanel — With Spouse tab', () => { await userEvent.click(screen.getByRole('button', { name: /With Spouse/ })); const select = screen.getByRole('combobox'); - expect(within(select).getByRole('option', { name: 'Alpha (Lv 2) ↔ #5' })).toBeInTheDocument(); + expect( + within(select).getByRole('option', { name: 'Alpha (Lv 2) ↔ #5' }), + ).toBeInTheDocument(); expect(within(select).getByRole('option', { name: 'Beta (Lv 3)' })).toBeInTheDocument(); }); From 8a88e844f8055f99784a75da42a91fd7c306508d Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 18:52:02 -0400 Subject: [PATCH 39/48] refactor: extract usePetCooldowns hook from pet-gallery --- frontend/CODE_QUALITY_REFACTOR.md | 34 ++- .../pet/collection/pet-gallery/index.tsx | 221 ++++++++---------- frontend/src/hooks/usePetCooldowns.ts | 58 +++++ 3 files changed, 189 insertions(+), 124 deletions(-) create mode 100644 frontend/src/hooks/usePetCooldowns.ts diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 300a7678..75bc6384 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -39,7 +39,7 @@ session can resume without re-deriving context. | 2 | Colocate panel CSS (split `overview/index.css`) | DONE | | 3 | Extract shared `useSpousePet` hook | DONE | | 4 | Decompose `breed` panel into orchestrator + parts | DONE | -| 5 | Extract `pet-gallery` cooldown logic into a hook | TODO | +| 5 | Extract `pet-gallery` cooldown logic into a hook | DONE | | 6 | Design-system decision + button consolidation | TODO | | 7 | Shared accessible modal + migrate bespoke modals | TODO | | 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | @@ -299,7 +299,7 @@ Co-Authored-By: Claude Opus 4.8 --- ## Step 5 — Extract `pet-gallery` cooldown logic into a hook -**Status:** TODO +**Status:** DONE **Goal:** Move readiness/cooldown computation out of the view. @@ -316,9 +316,34 @@ inlines readiness math (`!isPetReady(BigInt(p.readyAt)) || ...`) duplicated at l - Cooldown countdowns still tick live; ready/on-cooldown states unchanged. - Typecheck + lint pass. -**Outcome:** _(fill after completion)_ +**Outcome (done):** +- **NEW** `src/hooks/usePetCooldowns.ts` — `usePetCooldowns(pets)` returns `{ anyCooldown, + statusFor }`. Owns the 1s `setInterval` tick (only while `anyCooldown`) and a `statusFor(pet)` + helper returning `{ onCooldown, battleReady, battleOnCooldown, breedOnCooldown, trainOnCooldown, + battleLabel, breedLabel, trainLabel }`. The readiness math (previously inlined 3×) lives here once. +- `pet-gallery/index.tsx`: removed the inline `anyCooldown`/tick effect and the + `isPetReady`/`getTimeUntilReady` imports; now calls `usePetCooldowns(pets)` and reads + `cd = statusFor(pet)` once per card. The status block and Send button consume `cd.*` instead of + recomputing `isPetReady(BigInt(...))` inline. +- **Verified:** `format:check` clean; `tsc -b` 0; `eslint .` 0; **272/272 tests pass** (incl. + `pet-gallery.test.tsx` — its `@shared/core` mock already provides `isPetReady`/`getTimeUntilReady`, + now reached via the hook). +- **Note:** kept the hook frontend-local in `src/hooks/` since pet-gallery is its only consumer; + promote to `@shared/core` if another surface needs it. -**Commit message:** _(fill after completion)_ +**Commit message:** +``` +refactor(frontend): extract usePetCooldowns hook from pet-gallery + +Move the per-pet readiness math (duplicated three times in the gallery view) +and the 1s countdown tick into a usePetCooldowns hook that returns a +statusFor(pet) helper. The view now reads cd.* flags/labels instead of +recomputing isPetReady(BigInt(...)) inline, and the tick lives in the hook. + +Behavior unchanged. Typecheck, lint, and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` --- @@ -401,3 +426,4 @@ behavior preserved; typecheck + lint + tests pass. - 2026-06-22 — Step 2 — refactor(frontend): colocate interaction panel CSS - 2026-06-22 — Step 3 — refactor: extract shared useSpousePet hook - 2026-06-22 — Step 4 — refactor(frontend): decompose breed panel into parts +- 2026-06-22 — Step 5 — refactor(frontend): extract usePetCooldowns hook from pet-gallery diff --git a/frontend/src/components/pet/collection/pet-gallery/index.tsx b/frontend/src/components/pet/collection/pet-gallery/index.tsx index 856d3da9..a7d30944 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/index.tsx @@ -11,8 +11,6 @@ import { getPetSkill, getRarityColor, getRarityName, - getTimeUntilReady, - isPetReady, useChainCapabilities, usePetList, type Pet, @@ -32,6 +30,7 @@ import CreatePetModal from '@components/pet/creation/create-pet-modal'; import PetCollectionLayout from '@components/pet/collection/pet-collection-layout'; import SendPetModal from '@components/pet/transfer/send-pet-modal'; import { useNotifyError } from '@hooks/useNotifyError'; +import { usePetCooldowns } from '@hooks/usePetCooldowns'; import './index.css'; const PetGallery: React.FC = () => { @@ -40,27 +39,16 @@ const PetGallery: React.FC = () => { const notifyError = useNotifyError(); const [loading, setLoading] = useState(false); const [sendModalOpen, setSendModalOpen] = useState(false); - const [, setTick] = useState(0); const [createModalOpen, setCreateModalOpen] = useState(false); const [sendSelection, setSendSelection] = useState<{ pet: Pet; petId: bigint } | null>(null); + // Owns the 1s tick and the per-pet readiness math so the view stays declarative. + const { statusFor } = usePetCooldowns(pets); + useEffect(() => { setLoading(isLoading); }, [isLoading]); - // Tick every second while any pet is on cooldown so the countdown stays live. - const anyCooldown = pets.some( - (p) => - !isPetReady(BigInt(p.readyAt)) || - (p.breedReadyAt != null && !isPetReady(BigInt(p.breedReadyAt))) || - (p.trainReadyAt != null && !isPetReady(BigInt(p.trainReadyAt))), - ); - useEffect(() => { - if (!anyCooldown) return; - const id = setInterval(() => setTick((t) => t + 1), 1000); - return () => clearInterval(id); - }, [anyCooldown]); - useEffect(() => { if (!error) return; notifyError('Failed to load pet data. Please try again.', error, 'pet-list'); @@ -195,127 +183,120 @@ const PetGallery: React.FC = () => { {!loading && !error && pets.length > 0 && (
    - {pets.map((pet) => ( -
    -
    -
    - {getRarityName(pet.rarity)} -
    -
    {getPetElement(pet.dna)}
    - {getPetSkill(pet.speciesId) ? ( + {pets.map((pet) => { + const cd = statusFor(pet); + return ( +
    +
    - {getPetSkill(pet.speciesId)?.name} + {getRarityName(pet.rarity)}
    - ) : null} -
    {getPetAvatar(pet.dna)}
    -
    Lv. {pet.level}
    -
    - -
    -
    -

    {pet.name}

    - - {getPetClass(pet.dna)} · Gen{' '} - {pet.generation ?? getGeneration(pet.dna)} - -
    -
    - XP - - {getXpNumbers(pet).xpCurrent}/{getXpNumbers(pet).xpMax} - -
    -
    -
    +
    {getPetElement(pet.dna)}
    + {getPetSkill(pet.speciesId) ? ( +
    + {getPetSkill(pet.speciesId)?.name} +
    + ) : null} +
    {getPetAvatar(pet.dna)}
    +
    Lv. {pet.level}
    - {(pet.winCount > 0 || - pet.lossCount > 0 || - (pet.breedCount != null && pet.breedCount > 0)) && ( -
    - {pet.winCount}W - / - {pet.lossCount}L - {pet.breedCount != null && pet.breedCount > 0 && ( - - · {pet.breedCount} bred - - )} -
    - )} -
    -
    - {Object.entries(getPetProperties(pet)).map(([key, value]) => ( -
    - - {getPropertyEmoji(key)} +
    +
    +

    {pet.name}

    + + {getPetClass(pet.dna)} · Gen{' '} + {pet.generation ?? getGeneration(pet.dna)} - {value}
    - ))} -
    - - {(!isPetReady(BigInt(pet.readyAt)) || - (pet.breedReadyAt != null && - !isPetReady(BigInt(pet.breedReadyAt))) || - (pet.trainReadyAt != null && - !isPetReady(BigInt(pet.trainReadyAt)))) && ( -
    - {!isPetReady(BigInt(pet.readyAt)) && ( -
    - ⚔️ Battle ready in{' '} - {getTimeUntilReady(BigInt(pet.readyAt))} +
    + XP + + {getXpNumbers(pet).xpCurrent}/ + {getXpNumbers(pet).xpMax} + +
    +
    +
    +
    + {(pet.winCount > 0 || + pet.lossCount > 0 || + (pet.breedCount != null && pet.breedCount > 0)) && ( +
    + {pet.winCount}W + / + + {pet.lossCount}L + + {pet.breedCount != null && pet.breedCount > 0 && ( + + · {pet.breedCount} bred + + )}
    )} - {pet.breedReadyAt != null && - !isPetReady(BigInt(pet.breedReadyAt)) && ( +
    + +
    + {Object.entries(getPetProperties(pet)).map( + ([key, value]) => ( +
    + + {getPropertyEmoji(key)} + + {value} +
    + ), + )} +
    + + {cd.onCooldown && ( +
    + {cd.battleOnCooldown && (
    - 🥚 Breed ready in{' '} - {getTimeUntilReady(BigInt(pet.breedReadyAt))} + ⚔️ Battle ready in {cd.battleLabel}
    )} - {pet.trainReadyAt != null && - !isPetReady(BigInt(pet.trainReadyAt)) && ( + {cd.breedOnCooldown && (
    - 💪 Train ready in{' '} - {getTimeUntilReady(BigInt(pet.trainReadyAt))} + 🥚 Breed ready in {cd.breedLabel}
    )} -
    - )} + {cd.trainOnCooldown && ( +
    + 💪 Train ready in {cd.trainLabel} +
    + )} +
    + )} -
    - +
    + +
    -
    - ))} + ); + })}
    )} diff --git a/frontend/src/hooks/usePetCooldowns.ts b/frontend/src/hooks/usePetCooldowns.ts new file mode 100644 index 00000000..b152c741 --- /dev/null +++ b/frontend/src/hooks/usePetCooldowns.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import { getTimeUntilReady, isPetReady, type Pet } from '@shared/core'; + +export interface PetCooldownStatus { + /** True when any of the three cooldowns is still active. */ + onCooldown: boolean; + /** Battle cooldown (the pet's primary `readyAt`). */ + battleReady: boolean; + battleOnCooldown: boolean; + breedOnCooldown: boolean; + trainOnCooldown: boolean; + /** "Xh Ym" countdown labels — only meaningful while the matching cooldown is active. */ + battleLabel: string; + breedLabel: string; + trainLabel: string; +} + +export interface PetCooldowns { + /** True while any pet in the list is on cooldown (drives the live 1s tick). */ + anyCooldown: boolean; + /** Per-pet readiness flags + countdown labels, recomputed each tick. */ + statusFor: (pet: Pet) => PetCooldownStatus; +} + +const statusFor = (pet: Pet): PetCooldownStatus => { + const battleReady = isPetReady(BigInt(pet.readyAt)); + const breedOnCooldown = pet.breedReadyAt != null && !isPetReady(BigInt(pet.breedReadyAt)); + const trainOnCooldown = pet.trainReadyAt != null && !isPetReady(BigInt(pet.trainReadyAt)); + return { + onCooldown: !battleReady || breedOnCooldown || trainOnCooldown, + battleReady, + battleOnCooldown: !battleReady, + breedOnCooldown, + trainOnCooldown, + battleLabel: getTimeUntilReady(BigInt(pet.readyAt)), + breedLabel: pet.breedReadyAt != null ? getTimeUntilReady(BigInt(pet.breedReadyAt)) : '', + trainLabel: pet.trainReadyAt != null ? getTimeUntilReady(BigInt(pet.trainReadyAt)) : '', + }; +}; + +/** + * Cooldown readiness for a list of pets. Ticks once a second while any pet is on + * cooldown so the countdown labels stay live, and exposes a `statusFor(pet)` helper + * so the view never repeats the readiness math. + */ +export const usePetCooldowns = (pets: Pet[]): PetCooldowns => { + const [, setTick] = useState(0); + + const anyCooldown = pets.some((p) => statusFor(p).onCooldown); + + useEffect(() => { + if (!anyCooldown) return; + const id = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(id); + }, [anyCooldown]); + + return { anyCooldown, statusFor }; +}; From 6265b7e13ad2c5147f783d9f74d064ff844865bf Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 20:16:24 -0400 Subject: [PATCH 40/48] feat: extend NeonButton palette for app-wide adoption --- frontend/CODE_QUALITY_REFACTOR.md | 81 ++++++++++++++----- .../src/components/ui/neon-button/index.css | 22 +++++ .../src/components/ui/neon-button/index.tsx | 7 +- 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 75bc6384..a9fd6e7f 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -40,7 +40,7 @@ session can resume without re-deriving context. | 3 | Extract shared `useSpousePet` hook | DONE | | 4 | Decompose `breed` panel into orchestrator + parts | DONE | | 5 | Extract `pet-gallery` cooldown logic into a hook | DONE | -| 6 | Design-system decision + button consolidation | TODO | +| 6 | Design-system adoption — buttons (app-wide) | IN PROGRESS | | 7 | Shared accessible modal + migrate bespoke modals | TODO | | 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | @@ -347,30 +347,66 @@ Co-Authored-By: Claude Opus 4.8 --- -## Step 6 — Design-system decision + button consolidation -**Status:** TODO - -**Goal:** One button story across the app. - -**Why:** `ui/neon-*` exists but is used **only in `wallet/`**. Elsewhere: raw -` + ); } return ( - + ); }; diff --git a/frontend/src/components/pet/interactions/interactions.css b/frontend/src/components/pet/interactions/interactions.css index dc60816a..7593ca00 100644 --- a/frontend/src/components/pet/interactions/interactions.css +++ b/frontend/src/components/pet/interactions/interactions.css @@ -322,42 +322,13 @@ } } +/* Layout only — children (NeonButton / .cancel-button) style themselves. */ .action-controls { display: flex; gap: 12px; justify-content: center; & button { - padding: 12px 24px; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - text-transform: uppercase; - letter-spacing: 0.5px; - - &:first-child { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: #9effd4; - border: 1px solid rgb(0 255 157 / 48%); - border-radius: 2px; - box-shadow: inset 0 0 14px rgb(0 255 157 / 14%), 0 0 14px rgb(0 255 157 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(0 255 157 / 64%); - box-shadow: inset 0 0 20px rgb(0 255 157 / 22%), 0 0 22px rgb(0 255 157 / 36%); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } - } - @media (max-width: 768px) { width: 100%; max-width: 200px; @@ -370,11 +341,19 @@ } } +/* Secondary / ghost action (e.g. battle Cancel) — not a NeonButton tone. */ .cancel-button { - background: rgb(5 13 30 / 92%); - color: rgb(195 210 255 / 78%); + padding: 12px 24px; border: 1px solid var(--neon-border-soft); border-radius: 2px; + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: all 0.3s ease; + background: rgb(5 13 30 / 92%); + color: rgb(195 210 255 / 78%); &:hover { color: #f6f3ff; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index ddd05266..4084d009 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -206,7 +206,7 @@ const BattleSetup: React.FC = ({ )}
    - + {battleButtonLabel}
    Expires {formatExpiry(proposal.expiry)} - +
  • ); diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx index 1897d2d9..ab5f5dc4 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx @@ -54,7 +54,8 @@ const MarriageCard: React.FC = ({ pet, chain, petById, onDivo
    onDivorce(pet.id)} disabled={busy} > diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx index cfe7cc84..ddc36734 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx @@ -38,7 +38,8 @@ const OutgoingProposalRow: React.FC = ({
    Expires {formatExpiry(expirySec)} onCancel(pet.id)} disabled={busy} > diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx index 8caabdd0..553a0013 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx @@ -66,7 +66,8 @@ const ProposeTab: React.FC = ({ />
    void handlePropose()} > diff --git a/frontend/src/components/pet/interactions/panels/rename/index.tsx b/frontend/src/components/pet/interactions/panels/rename/index.tsx index 854ec392..f1486177 100644 --- a/frontend/src/components/pet/interactions/panels/rename/index.tsx +++ b/frontend/src/components/pet/interactions/panels/rename/index.tsx @@ -112,6 +112,7 @@ const RenamePanel: React.FC = ({ isStandaloneView = true }) =>
    diff --git a/frontend/src/components/pet/interactions/panels/train/index.tsx b/frontend/src/components/pet/interactions/panels/train/index.tsx index c54186b7..367b52d0 100644 --- a/frontend/src/components/pet/interactions/panels/train/index.tsx +++ b/frontend/src/components/pet/interactions/panels/train/index.tsx @@ -93,7 +93,11 @@ const TrainPanel: React.FC = ({ isStandaloneView = true }) => {
    - + {buttonLabel}
    From 7ff47cbc7325a7f1a1a702284055cefa0c4bc963 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 21:23:12 -0400 Subject: [PATCH 42/48] refactor: migrate dashboard hub buttons to NeonButton --- frontend/CODE_QUALITY_REFACTOR.md | 39 +++++- .../pet/interactions/overview/index.css | 120 +----------------- .../pet/interactions/overview/index.tsx | 55 ++++---- 3 files changed, 68 insertions(+), 146 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 62820212..292cf6cf 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -374,10 +374,14 @@ panel buttons styled via descendant selectors (`.action-controls button`). `size-xs`; widen `NeonButtonTone`/`NeonButtonSize`. Purely additive, no call sites changed. - **6b — Auth-gated action buttons (DONE):** compose `AuthActionButton` over `NeonButton` and migrate the 8 auth buttons + the coupled `accept-inline` raw button. See 6b outcome below. -- **6c — Non-auth action buttons:** `accept-inline`, hub `lab-breed-button` family, - `create-first-pet-button`, `retry-button`, `refresh`, `sync-metadata`, battle `cancel-button` → - `NeonButton` with the right tone/size; remove dead CSS. Tabs + close-X stay bespoke. -- **6d (optional) — NeonCard:** adopt for hub cards / state cards if it reduces bespoke card CSS. +- **6c — Hub action buttons (DONE):** the 6 `lab-breed-button` family buttons in the dashboard + hub → `NeonButton`; remove their CSS. (`accept-inline` was already done in 6b.) See 6c outcome. +- **6d — Remaining action buttons:** confirm-dialog accept (`action-button accept-button`), + pet-gallery `create-first-pet-button` + `retry-button` → `NeonButton`; then remove the + now-fully-dead `.action-button` base (interactions.css) + `.accept-button`/`.confirm-accept` + (marriage). Leave structural/icon/ghost buttons bespoke: tabs, close-X, `confirm-cancel`, + `refresh` (icon), battle `.cancel-button` (ghost), `sync-metadata`. +- **6e (optional) — NeonCard:** adopt for hub cards / state cards if it reduces bespoke card CSS. **Acceptance (whole step):** consistent `NeonButton` usage for action buttons; no behavior change; typecheck + lint + tests pass. Visual check in-app recommended (button themes are CSS). @@ -449,6 +453,32 @@ tests pass. Co-Authored-By: Claude Opus 4.8 ``` +### 6c outcome (done) +- Migrated the 6 dashboard-hub card buttons in `overview/index.tsx` from + `
    - +
    @@ -249,15 +251,16 @@ const PetInteractions: React.FC = () => {
    - +
    @@ -272,14 +275,15 @@ const PetInteractions: React.FC = () => { ? `From ${capabilities.levelUpFee.amount} ${capabilities.levelUpFee.symbol} — cost rises with your pet's level.` : 'Costs a small SOL fee per level.'}
    - +
    @@ -292,14 +296,15 @@ const PetInteractions: React.FC = () => {
    {trainFeeLabel}
    - +
    @@ -312,14 +317,15 @@ const PetInteractions: React.FC = () => {
    Propose, accept, or divorce.
    - +
    @@ -334,14 +340,15 @@ const PetInteractions: React.FC = () => { ? `Requires level ${capabilities.renameMinLevel} or higher.` : 'Pick a new identity for your companion.'}
    - +
    )} From 9689b5429cae4bc0cfb9f48517505e90b7812de3 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 21:42:33 -0400 Subject: [PATCH 43/48] fix(shared): add missing chainId deps to settle callbacks --- frontend/CODE_QUALITY_REFACTOR.md | 1 + shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts | 2 +- shared/src/hooks/useBreedPets.ts | 2 +- shared/src/hooks/useCreatePet.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 292cf6cf..e342fd0a 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -537,3 +537,4 @@ behavior preserved; typecheck + lint + tests pass. - 2026-06-22 — Step 6a — feat(frontend): extend NeonButton palette for app-wide adoption - 2026-06-22 — Step 6b — refactor(frontend): render auth action buttons via NeonButton - 2026-06-22 — Step 6c — refactor(frontend): migrate dashboard hub buttons to NeonButton +- 2026-06-22 — fix(shared): resolve 3 pre-existing exhaustive-deps lint warnings diff --git a/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts b/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts index 8455e389..81b8e880 100644 --- a/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts +++ b/shared/src/hooks/chains/ethereum/useEvmBattleFlow.ts @@ -82,7 +82,7 @@ export const useEvmBattleFlow = ({ requestHash, enabled, onResolved }: UseEvmBat onError: (e) => { setError(e as Error); setPhase('error'); }, }, ); - }, [gameLogic, gameLogicAbi, settle]); + }, [gameLogic, gameLogicAbi, chainId, settle]); // 2. Wait for Pyth Entropy Revealed, then settle. useWatchEntropyFulfillment({ diff --git a/shared/src/hooks/useBreedPets.ts b/shared/src/hooks/useBreedPets.ts index b369d7e6..2e58e183 100644 --- a/shared/src/hooks/useBreedPets.ts +++ b/shared/src/hooks/useBreedPets.ts @@ -100,7 +100,7 @@ export const useBreedPets = (options?: UseBreedPetsOptions) => { gas: 800000n, chainId: evm.chainId, }); - }, [evm?.gameLogic.address, evm?.gameLogic.abi, settle]); + }, [evm?.gameLogic.address, evm?.gameLogic.abi, evm?.chainId, settle]); useWatchEntropyFulfillment({ entropyAddress: isEvm ? (entropyAddress as `0x${string}` | undefined) : undefined, diff --git a/shared/src/hooks/useCreatePet.ts b/shared/src/hooks/useCreatePet.ts index cda8fd5c..35eaf8ae 100644 --- a/shared/src/hooks/useCreatePet.ts +++ b/shared/src/hooks/useCreatePet.ts @@ -94,7 +94,7 @@ export const useCreatePet = (options?: PetMutationOptions): PetMutationResult console.error('[settleMint]', e) }, ); - }, [evm?.gameLogic.address, evm?.gameLogic.abi, settle]); + }, [evm?.gameLogic.address, evm?.gameLogic.abi, evm?.chainId, settle]); // 2. Watch Pyth Entropy `Revealed` (caller = gameLogic, sequenceNumber = requestId). useWatchEntropyFulfillment({ From d9147eae4b07667cb321d1ec27d2c94c8036e551 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 21:55:14 -0400 Subject: [PATCH 44/48] refactor: migrate remaining action buttons to NeonButton --- frontend/CODE_QUALITY_REFACTOR.md | 46 ++++++++++++--- .../pet/collection/pet-gallery/index.css | 58 +------------------ .../pet/collection/pet-gallery/index.tsx | 13 ++--- .../pet/interactions/interactions.css | 46 --------------- .../interactions/panels/marriage/index.css | 18 ------ .../marriage/parts/accept-confirm-dialog.tsx | 10 +--- 6 files changed, 48 insertions(+), 143 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index e342fd0a..e4451f8a 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -40,7 +40,7 @@ session can resume without re-deriving context. | 3 | Extract shared `useSpousePet` hook | DONE | | 4 | Decompose `breed` panel into orchestrator + parts | DONE | | 5 | Extract `pet-gallery` cooldown logic into a hook | DONE | -| 6 | Design-system adoption — buttons (app-wide) | IN PROGRESS | +| 6 | Design-system adoption — buttons (app-wide) | DONE (6a–6d; 6e optional) | | 7 | Shared accessible modal + migrate bespoke modals | TODO | | 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | @@ -376,12 +376,14 @@ panel buttons styled via descendant selectors (`.action-controls button`). migrate the 8 auth buttons + the coupled `accept-inline` raw button. See 6b outcome below. - **6c — Hub action buttons (DONE):** the 6 `lab-breed-button` family buttons in the dashboard hub → `NeonButton`; remove their CSS. (`accept-inline` was already done in 6b.) See 6c outcome. -- **6d — Remaining action buttons:** confirm-dialog accept (`action-button accept-button`), - pet-gallery `create-first-pet-button` + `retry-button` → `NeonButton`; then remove the - now-fully-dead `.action-button` base (interactions.css) + `.accept-button`/`.confirm-accept` - (marriage). Leave structural/icon/ghost buttons bespoke: tabs, close-X, `confirm-cancel`, - `refresh` (icon), battle `.cancel-button` (ghost), `sync-metadata`. -- **6e (optional) — NeonCard:** adopt for hub cards / state cards if it reduces bespoke card CSS. +- **6d — Remaining action buttons (DONE):** confirm-dialog Confirm, pet-gallery + `create-first-pet` + `retry` → `NeonButton`; removed dead `.action-button`/`.breed-button`/ + `.battle-button` (interactions.css) + `.accept-button`/`.confirm-accept` (marriage) + + `.create-first-pet-button`/`.retry-button` (pet-gallery). See 6d outcome. +- **6e (optional) — leftovers:** the per-pet `send-button` (stateful ready/cooldown) is the only + remaining themed action button left bespoke; migrate to `NeonButton tone={ready?emerald:amber}` + if desired. Also optional `NeonCard` for hub/state cards. Intentionally bespoke (not buttons in + the tone sense): tabs, close-X, `confirm-cancel`/ghost cancels, `refresh` icon. **Acceptance (whole step):** consistent `NeonButton` usage for action buttons; no behavior change; typecheck + lint + tests pass. Visual check in-app recommended (button themes are CSS). @@ -479,6 +481,35 @@ No behavior change. Typecheck, lint, and all 272 tests pass. Co-Authored-By: Claude Opus 4.8 ``` +### 6d outcome (done) +- Migrated the last action buttons to `NeonButton`: + - marriage confirm dialog Confirm → `tone="emerald" size="sm"` (Cancel stays a ghost button). + - pet-gallery `Try Again` → `tone="magenta" size="sm"`; `Create your first pet` → `tone="cyan"`. +- Removed now-dead CSS: `.action-button` + the long-dead `.breed-button`/`.battle-button` + (interactions.css); `.accept-button`/`.confirm-accept` (marriage); `.create-first-pet-button`/ + `.retry-button` (pet-gallery), and dropped the two from pet-gallery's reduced-motion rule. + (This also clears the dead-CSS item that was queued for Step 8.) +- **Verified:** no dangling class refs; `format:check` clean; `tsc -b` 0; `eslint .` 0; + **272/272 tests pass**. +- **Step 6 (buttons) is now effectively complete.** Remaining bespoke by design: tabs, close-X, + ghost cancels, `refresh` icon. Only the stateful per-pet `send-button` remains as an optional + migration (see 6e). + +**6d commit message:** +``` +refactor(frontend): migrate remaining action buttons to NeonButton + +Move the marriage confirm button and the pet-gallery create/retry buttons to +NeonButton, and delete the now-dead button CSS (.action-button, .breed-button, +.battle-button, .accept-button, .confirm-accept, .create-first-pet-button, +.retry-button). Completes the app-wide button adoption except the stateful +per-pet send button. + +No behavior change. Typecheck, lint, and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` + --- ## Step 7 — Shared accessible modal + migrate bespoke modals @@ -538,3 +569,4 @@ behavior preserved; typecheck + lint + tests pass. - 2026-06-22 — Step 6b — refactor(frontend): render auth action buttons via NeonButton - 2026-06-22 — Step 6c — refactor(frontend): migrate dashboard hub buttons to NeonButton - 2026-06-22 — fix(shared): resolve 3 pre-existing exhaustive-deps lint warnings +- 2026-06-22 — Step 6d — refactor(frontend): migrate remaining action buttons to NeonButton diff --git a/frontend/src/components/pet/collection/pet-gallery/index.css b/frontend/src/components/pet/collection/pet-gallery/index.css index 4be3fe15..5e8b19c4 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.css @@ -42,28 +42,6 @@ } } -.retry-button { - background: rgb(5 13 30 / 92%); - color: var(--neon-magenta); - border: 1px solid rgb(255 110 196 / 42%); - border-radius: 2px; - padding: var(--spacing-sm) var(--spacing-md); - font-size: var(--font-size-sm); - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - cursor: pointer; - margin-block-start: var(--spacing-sm); - transition: var(--transition); - box-shadow: inset 0 0 12px rgb(255 110 196 / 14%), 0 0 12px rgb(255 110 196 / 18%); - - &:hover { - transform: translateY(-1px); - border-color: var(--neon-magenta); - box-shadow: inset 0 0 16px rgb(255 110 196 / 22%), 0 0 18px rgb(255 110 196 / 32%); - } -} - /* Empty State — summoning altar hero. Fits the remaining surface height. */ .empty-state { flex: 1 1 auto; @@ -225,38 +203,6 @@ } } -.create-first-pet-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: var(--neon-cyan); - border: 1px solid rgb(125 214 255 / 50%); - border-radius: 2px; - padding: 10px 18px; - font-size: var(--font-size-sm); - font-weight: 700; - cursor: pointer; - transition: var(--transition); - text-transform: uppercase; - letter-spacing: 0.95px; - box-shadow: inset 0 0 12px rgb(125 214 255 / 16%), 0 0 12px rgb(125 214 255 / 20%); - flex-shrink: 0; - - &:hover { - transform: translateY(-2px); - color: var(--neon-violet); - border-color: rgb(181 140 255 / 60%); - box-shadow: inset 0 0 22px rgb(181 140 255 / 22%), 0 0 28px rgb(181 140 255 / 38%); - } - - &:active { - transform: translateY(0); - } - - @media (max-width: 480px) { - padding: 8px 14px; - font-size: var(--font-size-xs); - } -} - /* Pet grid with container queries */ .pet-grid { display: grid; @@ -665,9 +611,7 @@ @media (prefers-reduced-motion: reduce) { .pet-card, - .create-first-pet-button, - .send-button, - .retry-button { + .send-button { transition: none; transform: none; } diff --git a/frontend/src/components/pet/collection/pet-gallery/index.tsx b/frontend/src/components/pet/collection/pet-gallery/index.tsx index a7d30944..877eed51 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/index.tsx @@ -26,6 +26,7 @@ import Icon, { SendIcon, SparklesIcon, } from '@components/ui/icon'; +import NeonButton from '@components/ui/neon-button'; import CreatePetModal from '@components/pet/creation/create-pet-modal'; import PetCollectionLayout from '@components/pet/collection/pet-collection-layout'; import SendPetModal from '@components/pet/transfer/send-pet-modal'; @@ -113,9 +114,9 @@ const PetGallery: React.FC = () => { Failed to load pet data. Please try again.

    - +
    )} @@ -170,14 +171,10 @@ const PetGallery: React.FC = () => {

    Awaken your first companion

    Step into the altar — name a pet and bring it to life.

    - +
    )} diff --git a/frontend/src/components/pet/interactions/interactions.css b/frontend/src/components/pet/interactions/interactions.css index 7593ca00..93d594da 100644 --- a/frontend/src/components/pet/interactions/interactions.css +++ b/frontend/src/components/pet/interactions/interactions.css @@ -185,52 +185,6 @@ font-size: 0.9em; margin-top: 8px; } -.action-button { - padding: 16px 24px; - border: 1px solid rgb(125 214 255 / 50%); - border-radius: 2px; - font-size: 16px; - font-weight: 700; - cursor: pointer; - transition: all 0.3s ease; - text-transform: uppercase; - letter-spacing: 0.95px; - min-width: 0; - text-align: center; - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: var(--neon-cyan); - box-shadow: inset 0 0 14px rgb(125 214 255 / 16%), 0 0 14px rgb(125 214 255 / 22%); - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } -} - -.breed-button { - color: var(--neon-violet); - border-color: rgb(181 140 255 / 50%); - box-shadow: inset 0 0 14px rgb(181 140 255 / 16%), 0 0 14px rgb(181 140 255 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(181 140 255 / 70%); - box-shadow: inset 0 0 20px rgb(181 140 255 / 24%), 0 0 22px rgb(181 140 255 / 38%); - } -} - -.battle-button { - color: var(--neon-magenta); - border-color: rgb(255 110 196 / 50%); - box-shadow: inset 0 0 14px rgb(255 110 196 / 16%), 0 0 14px rgb(255 110 196 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(255 110 196 / 70%); - box-shadow: inset 0 0 20px rgb(255 110 196 / 24%), 0 0 22px rgb(255 110 196 / 38%); - } -} .name-input { display: flex; diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.css b/frontend/src/components/pet/interactions/panels/marriage/index.css index d56fd775..e7b5c978 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.css +++ b/frontend/src/components/pet/interactions/panels/marriage/index.css @@ -93,19 +93,6 @@ grid-column: 1 / -1; } -.accept-button { - color: #9effd4; - border-color: rgb(0 255 157 / 48%); - box-shadow: inset 0 0 14px rgb(0 255 157 / 14%), 0 0 14px rgb(0 255 157 / 22%); - grid-column: 1 / -1; - - &:hover:not(:disabled) { - transform: translateY(-2px); - border-color: rgb(0 255 157 / 64%); - box-shadow: inset 0 0 20px rgb(0 255 157 / 22%), 0 0 22px rgb(0 255 157 / 36%); - } -} - /* ── Marriage list & cards ────────────────────────────────────────────── */ .marriage-list { @@ -406,8 +393,3 @@ cursor: not-allowed; } } - -.confirm-accept { - padding: 8px 20px !important; - font-size: 13px !important; -} diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx index f061678c..abb01496 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import NeonButton from '@components/ui/neon-button'; import type { PendingAccept } from '../types'; type AcceptConfirmDialogProps = { @@ -31,14 +32,9 @@ const AcceptConfirmDialog: React.FC = ({ - +
    From 45d8bde3e28ee40d946b3b156590fdd431498f53 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 22:07:50 -0400 Subject: [PATCH 45/48] refactor: migrate send-pet modal to NeonModal --- frontend/CODE_QUALITY_REFACTOR.md | 67 +++++++-- .../pet/transfer/send-pet-modal/index.css | 137 ++---------------- .../pet/transfer/send-pet-modal/index.tsx | 106 +++++++------- frontend/tests/setup.ts | 8 + 4 files changed, 119 insertions(+), 199 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index e4451f8a..098f2d12 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -41,7 +41,7 @@ session can resume without re-deriving context. | 4 | Decompose `breed` panel into orchestrator + parts | DONE | | 5 | Extract `pet-gallery` cooldown logic into a hook | DONE | | 6 | Design-system adoption — buttons (app-wide) | DONE (6a–6d; 6e optional) | -| 7 | Shared accessible modal + migrate bespoke modals | TODO | +| 7 | Migrate bespoke modals to NeonModal | IN PROGRESS (7a done) | | 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | Statuses: `TODO` → `IN PROGRESS` → `DONE` (or `SKIPPED` with reason). @@ -512,27 +512,61 @@ Co-Authored-By: Claude Opus 4.8 --- -## Step 7 — Shared accessible modal + migrate bespoke modals -**Status:** TODO +## Step 7 — Migrate bespoke modals to NeonModal +**Status:** IN PROGRESS (7a done; 7b, 7c pending) -**Goal:** All modals use one accessible primitive. +**Goal:** All modals use `NeonModal` (react-modal) for `role="dialog"`, `aria-modal`, +Escape-to-close, focus trap, and overlay-click close. -**Why:** `panels/marriage/parts/accept-confirm-dialog.tsx`, `create-pet-modal`, -`send-pet-modal` hand-roll modal markup with **no `role="dialog"`, no Escape-to-close, -no focus trap**. `ui/neon-modal` exists but is underused. +**Why:** `send-pet-modal`, `create-pet-modal`, and +`panels/marriage/parts/accept-confirm-dialog.tsx` hand-roll modal markup with no a11y. +`NeonModal` already wraps react-modal and provides all of it. -**Plan:** -- Ensure `NeonModal` (or a chosen modal primitive) provides `role="dialog"`, - `aria-modal`, Escape-to-close, focus trap, and overlay-click close. -- Migrate the three bespoke modals onto it. -- Depends on Step 6's design-system decision. +**Pattern (per modal):** replace the bespoke overlay/dialog/header/close with +``; move the +body markup in as children; primary button → `NeonButton`, secondary stays a ghost `.cancel`; +rewrite the modal's CSS to body-content-only (scoped under the `contentClassName`), dropping the +chrome rules NeonModal now owns. -**Acceptance:** Keyboard (Esc, tab-trap) + screen-reader semantics work; existing -behavior preserved; typecheck + lint + tests pass. +**Test note:** react-modal needs an app element + renders via a portal. A one-time +`#root` element was added to `tests/setup.ts` (landed in 7a) so all modal tests work; existing +tests use `screen` queries which see the portal, so they pass unchanged. -**Outcome:** _(fill after completion)_ +**Sub-steps (one commit each):** +- **7a — send-pet-modal (DONE):** see outcome below. +- **7b — create-pet-modal:** same pattern; submit → `NeonButton`. Title "Create Your First Pet". +- **7c — marriage accept-confirm-dialog:** Confirm already a `NeonButton` (6d); wrap in NeonModal, + title "💒 Accept Proposal?", keep ghost Cancel; remove `.marriage-confirm-overlay`/`-dialog`/ + `.confirm-title`/`.confirm-body`/`.confirm-actions` chrome CSS as appropriate. Verify the + marriage test's confirm-flow (`Accept Proposal` text + `/Confirm/` button) still passes. + +**Acceptance (whole step):** keyboard (Esc, tab-trap) + `role=dialog` work; behavior preserved; +typecheck + lint + tests pass. + +### 7a outcome (done) +- `send-pet-modal/index.tsx` now renders `` + with the preview/recipient/actions as children; removed the `if (!isOpen) return null` (NeonModal + handles `isOpen`). Send button → ``; Cancel stays a ghost button. +- `send-pet-modal/index.css` rewritten to body-content-only, scoped under `.send-pet-body` + (dropped the `.send-pet-modal` overlay, `.dialog`, `.header`, `.close`, `.body`, `.send` chrome; + `.cancel` made self-contained). +- `tests/setup.ts`: added a `#root` element so react-modal's `setAppElement('#root')` works under jsdom. +- **Verified:** `format:check` clean; `tsc -b` 0; `eslint .` 0; send-pet test 8/8; **272/272 pass**. + +**7a commit message:** +``` +refactor(frontend): migrate send-pet modal to NeonModal -**Commit message:** _(fill after completion)_ +Render SendPetModal via NeonModal (react-modal) for focus-trap, Escape, and +role=dialog, replacing the hand-rolled overlay/dialog/header. The Send button +becomes a NeonButton; the CSS is reduced to body content scoped under +.send-pet-body. Add a #root element to the test setup so react-modal works +under jsdom. + +Behavior preserved. Typecheck, lint, and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` --- @@ -570,3 +604,4 @@ behavior preserved; typecheck + lint + tests pass. - 2026-06-22 — Step 6c — refactor(frontend): migrate dashboard hub buttons to NeonButton - 2026-06-22 — fix(shared): resolve 3 pre-existing exhaustive-deps lint warnings - 2026-06-22 — Step 6d — refactor(frontend): migrate remaining action buttons to NeonButton +- 2026-06-22 — Step 7a — refactor(frontend): migrate send-pet modal to NeonModal diff --git a/frontend/src/components/pet/transfer/send-pet-modal/index.css b/frontend/src/components/pet/transfer/send-pet-modal/index.css index 104cc48c..40a22cff 100644 --- a/frontend/src/components/pet/transfer/send-pet-modal/index.css +++ b/frontend/src/components/pet/transfer/send-pet-modal/index.css @@ -1,100 +1,5 @@ -.send-pet-modal { - --modal-max-height: min(90vh, calc(100dvh - var(--main-header-offset) - 2 * var(--spacing-lg))); - - position: fixed; - inset: 0; - background: rgb(3 8 18 / 72%); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 1.5rem; - box-sizing: border-box; - - @media (max-width: 768px) { - padding: 0.5rem; - } - - .dialog { - background: linear-gradient(180deg, rgb(10 16 32 / 96%), rgb(6 10 22 / 98%)); - border: 1px solid var(--neon-border-soft); - border-radius: 16px; - box-shadow: inset 0 0 24px rgb(125 214 255 / 8%), 0 0 24px rgb(181 140 255 / 22%), - 0 20px 56px rgb(0 0 0 / 50%); - max-height: var(--modal-max-height); - overflow: hidden; - display: flex; - flex-direction: column; - flex: 1; - box-sizing: border-box; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-xl); - border-block-end: 1px solid var(--neon-border-soft); - background: linear-gradient(180deg, rgb(10 24 52 / 56%), rgb(8 18 40 / 35%)); - color: #f6f3ff; - border-radius: 16px 16px 0 0; - width: 100%; - box-sizing: border-box; - - & h2 { - margin: 0; - font-family: var(--title-font); - font-size: 1.5rem; - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: inherit; - text-shadow: 0 0 12px var(--neon-text-glow); - word-break: break-word; - - @media (max-width: 768px) { - font-size: 1.25rem; - } - } - - @media (max-width: 768px) { - padding: 1.5rem 1.5rem 1rem 1.5rem; - } - } - - .close { - background: none; - border: 1px solid transparent; - font-size: 1.75rem; - color: rgb(195 210 255 / 70%); - cursor: pointer; - padding: 0; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; - transition: all 0.2s; - - &:hover { - color: var(--neon-cyan); - border-color: rgb(125 214 255 / 42%); - box-shadow: 0 0 12px rgb(125 214 255 / 28%); - } - } - - .body { - padding: var(--spacing-xl); - overflow-y: auto; - box-sizing: border-box; - - @media (max-width: 768px) { - padding: 1.25rem; - } - } - +/* Body content only — the overlay/dialog/header/close chrome comes from . */ +.send-pet-body { .preview { background: rgb(5 13 30 / 78%); border: 1px solid var(--neon-border-soft); @@ -185,30 +90,22 @@ display: flex; gap: 12px; justify-content: flex-end; + align-items: center; padding-top: 8px; width: 100%; box-sizing: border-box; } - .cancel, - .send { + /* Secondary / ghost cancel — not a NeonButton tone. */ + .cancel { padding: 0.75rem 2rem; border-radius: 2px; font-size: 1rem; font-weight: 700; - cursor: pointer; - transition: all 0.2s; letter-spacing: 0.7px; text-transform: uppercase; - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } - } - - .cancel { + cursor: pointer; + transition: all 0.2s; background: rgb(5 13 30 / 92%); color: rgb(195 210 255 / 78%); border: 1px solid var(--neon-border-soft); @@ -218,24 +115,10 @@ border-color: var(--neon-border-strong); box-shadow: 0 0 12px rgb(125 214 255 / 22%); } - } - .send { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: var(--neon-cyan); - border: 1px solid rgb(125 214 255 / 50%); - box-shadow: inset 0 0 16px rgb(125 214 255 / 18%), 0 0 16px rgb(125 214 255 / 24%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - color: var(--neon-violet); - border-color: rgb(181 140 255 / 60%); - box-shadow: inset 0 0 22px rgb(181 140 255 / 22%), 0 0 28px rgb(181 140 255 / 38%); - } - - @media (max-width: 768px) { - padding: 0.5rem 1.5rem; - font-size: 0.9rem; + &:disabled { + opacity: 0.55; + cursor: not-allowed; } } } diff --git a/frontend/src/components/pet/transfer/send-pet-modal/index.tsx b/frontend/src/components/pet/transfer/send-pet-modal/index.tsx index 9b87671c..3060fe93 100644 --- a/frontend/src/components/pet/transfer/send-pet-modal/index.tsx +++ b/frontend/src/components/pet/transfer/send-pet-modal/index.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { useChainCapabilities, usePetList, useTransferPet } from '@shared/core'; import TransactionStatus from '@components/common/transaction-status'; +import NeonButton from '@components/ui/neon-button'; +import NeonModal from '@components/ui/neon-modal'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; import './index.css'; @@ -84,67 +86,59 @@ const SendPetModal: React.FC = ({ isOpen, onClose, pet, petId } }; - if (!isOpen) return null; - return ( -
    -
    e.stopPropagation()}> -
    -

    Send Pet

    - + +
    +

    {pet.name}

    +
    +

    + Level: {pet.level} +

    +

    + DNA: {pet.dna.toString()} +

    +

    + Rarity: {pet.rarity} +

    +
    -
    -
    -

    {pet.name}

    -
    -

    - Level: {pet.level} -

    -

    - DNA: {pet.dna.toString()} -

    -

    - Rarity: {pet.rarity} -

    -
    -
    - -
    - - { - setRecipientAddress(e.target.value); - setInputInvalid(false); - }} - placeholder={addressPlaceholder} - disabled={isPending} - className={inputInvalid ? 'invalid' : ''} - /> -
    - -
    - - -
    -
    +
    + + { + setRecipientAddress(e.target.value); + setInputInvalid(false); + }} + placeholder={addressPlaceholder} + disabled={isPending} + className={inputInvalid ? 'invalid' : ''} + /> +
    - +
    + + + {isPending ? 'Sending...' : 'Send Pet'} +
    -
    + + + ); }; diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts index 97d9b31b..8344430b 100644 --- a/frontend/tests/setup.ts +++ b/frontend/tests/setup.ts @@ -7,6 +7,14 @@ import { afterEach, vi } from 'vitest'; // tests never exercise that path, so stub it to keep the barrel importable. vi.mock('@switchboard-xyz/on-demand', () => ({})); +// react-modal (used by ) calls Modal.setAppElement('#root'); provide +// the element so modal-rendering components don't throw under jsdom. +if (!document.getElementById('root')) { + const root = document.createElement('div'); + root.id = 'root'; + document.body.appendChild(root); +} + // Unmount React trees between tests so renderHook/render don't leak state. afterEach(() => { cleanup(); From 8f352ebc3757f2033fa3e6e3d68dc42b51912626 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 22:30:09 -0400 Subject: [PATCH 46/48] refactor(frontend): migrate create-pet modal to NeonModal --- frontend/CODE_QUALITY_REFACTOR.md | 35 +++- .../pet/creation/create-pet-modal/index.css | 155 +----------------- .../pet/creation/create-pet-modal/index.tsx | 117 +++++++------ .../pet/creation/create-pet-modal.test.tsx | 10 +- 4 files changed, 102 insertions(+), 215 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 098f2d12..87d3bf8b 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -534,7 +534,7 @@ tests use `screen` queries which see the portal, so they pass unchanged. **Sub-steps (one commit each):** - **7a — send-pet-modal (DONE):** see outcome below. -- **7b — create-pet-modal:** same pattern; submit → `NeonButton`. Title "Create Your First Pet". +- **7b — create-pet-modal (DONE):** same pattern; submit → `NeonButton`. See outcome below. - **7c — marriage accept-confirm-dialog:** Confirm already a `NeonButton` (6d); wrap in NeonModal, title "💒 Accept Proposal?", keep ghost Cancel; remove `.marriage-confirm-overlay`/`-dialog`/ `.confirm-title`/`.confirm-body`/`.confirm-actions` chrome CSS as appropriate. Verify the @@ -568,6 +568,33 @@ Behavior preserved. Typecheck, lint, and all 272 tests pass. Co-Authored-By: Claude Opus 4.8 ``` +### 7b outcome (done) +- `create-pet-modal/index.tsx` now renders `Create Your First Pet} + contentClassName="create-pet-body">`; removed the `if (!isOpen) return null`. Submit → + ``. +- `create-pet-modal/index.css` rewritten to body-content-only under `.create-pet-body` + (dropped overlay/`.dialog`/`.header`/`.close`/`.body`/`.submit` chrome). +- Test fix: the close-button test now targets `name: 'Close modal'` (NeonModal's close + `aria-label`) instead of the old `×`. The role dump confirmed the modal now exposes + `role="dialog"` + `aria-modal="true"` (the a11y win). +- **Verified:** `src` `format:check` clean; `tsc -b` 0; `eslint .` 0; create-pet 7/7; + **272/272 pass**. (Note: `tests/**` predates Prettier and isn't in the `format:check` scope — + unrelated to this work; could be a Step 8 cleanup.) + +**7b commit message:** +``` +refactor(frontend): migrate create-pet modal to NeonModal + +Render CreatePetModal via NeonModal for focus-trap, Escape, and role=dialog, +replacing the hand-rolled chrome. Submit becomes a NeonButton; CSS reduced to +body content under .create-pet-body. Update the close-button test to target +NeonModal's "Close modal" control. + +Behavior preserved. Typecheck, lint, and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` + --- ## Step 8 — Minor cleanups @@ -582,8 +609,10 @@ Co-Authored-By: Claude Opus 4.8 using `styles/variables.css` tokens where practical. - Optional: normalize a few camelCase non-hook filenames (`constants/interactionRoutes.ts`, `petsContractParams.ts`) only if it doesn't churn imports excessively. -- **Remove dead CSS** carried over from Step 2: `.breed-button`, `.battle-button`, - `.transaction-info` in `interactions/interactions.css` (verified unused in Step 2). +- `.breed-button`/`.battle-button` dead CSS — already removed in 6d. `.transaction-info` still + unused; remove it (interactions.css). +- Consider extending the `format`/`format:check` globs to include `tests/**` and reformatting + the ~42 unformatted test files (out of scope for the refactor steps; pure formatting). **Acceptance:** Lint clean; no visual/behavior change. diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.css b/frontend/src/components/pet/creation/create-pet-modal/index.css index 11760b12..92b813f8 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.css +++ b/frontend/src/components/pet/creation/create-pet-modal/index.css @@ -1,110 +1,14 @@ -/* Tokens: frontend/src/styles/variables.css — modal-only overrides below */ +/* Body content only — the overlay/dialog/header/close chrome comes from . + Tokens: frontend/src/styles/variables.css */ -.create-pet-modal { - --modal-max-width: min(500px, 100%); - --modal-max-height: min(90vh, calc(100dvh - var(--main-header-offset) - 2 * var(--spacing-lg))); - --close-size: 32px; - --backdrop: rgb(3 8 18 / 72%); - --elevated-shadow: inset 0 0 24px rgb(125 214 255 / 8%), 0 0 24px rgb(181 140 255 / 22%), - 0 20px 56px rgb(0 0 0 / 50%); +.create-pet-body { --focus-ring: 0 0 0 2px var(--neon-cyan), 0 0 12px rgb(125 214 255 / 32%); - position: fixed; - inset: 0; - background: var(--backdrop); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: var(--z-modal); - padding: var(--spacing-lg); - - @media (max-width: 768px) { - padding: var(--spacing-sm); - } - - .dialog { - background: linear-gradient(180deg, rgb(10 16 32 / 96%), rgb(6 10 22 / 98%)); - border: 1px solid var(--neon-border-soft); - border-radius: var(--border-radius); - box-shadow: var(--elevated-shadow); - max-width: var(--modal-max-width); - width: 100%; - max-height: var(--modal-max-height); - overflow: hidden; - display: flex; - flex-direction: column; - } - - .header { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md); - border-block-end: 1px solid var(--neon-border-soft); - background: linear-gradient(180deg, rgb(10 24 52 / 56%), rgb(8 18 40 / 35%)); - color: #f6f3ff; - border-radius: var(--border-radius) var(--border-radius) 0 0; - width: 100%; - - & h2 { - margin: 0; - font-family: var(--title-font); - font-size: var(--font-size-2xl); - font-weight: 700; - letter-spacing: 0.6px; - text-transform: uppercase; - color: inherit; - text-shadow: 0 0 12px var(--neon-text-glow); - } - - @media (max-width: 768px) { - padding: var(--spacing-lg) var(--spacing-lg) var(--spacing-md); - - & h2 { - font-size: var(--font-size-xl); - } - } - } - - .close { - background: none; - border: 1px solid transparent; - font-size: 1.75rem; - line-height: 1; + & > p { + margin: 0 0 var(--spacing-xl); color: rgb(195 210 255 / 70%); - cursor: pointer; - padding: 0; - width: var(--close-size); - height: var(--close-size); - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; - transition: var(--transition-fast); - - &:hover { - color: var(--neon-cyan); - border-color: rgb(125 214 255 / 42%); - box-shadow: 0 0 12px rgb(125 214 255 / 28%); - } - } - - .body { - padding: var(--spacing-xl); - overflow-y: auto; - flex: 1; - - & p { - margin: 0 0 var(--spacing-xl); - color: rgb(195 210 255 / 70%); - font-size: var(--font-size-base); - line-height: 1.55; - } - - @media (max-width: 768px) { - padding: var(--spacing-lg); - } + font-size: var(--font-size-base); + line-height: 1.55; } .form { @@ -160,49 +64,4 @@ } } } - - .submit { - padding: var(--spacing-md) var(--spacing-xl); - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: var(--neon-cyan); - border: 1px solid rgb(125 214 255 / 50%); - border-radius: 2px; - font-size: var(--font-size-base); - font-weight: 700; - cursor: pointer; - transition: var(--transition); - text-transform: uppercase; - letter-spacing: 0.95px; - box-shadow: inset 0 0 16px rgb(125 214 255 / 18%), 0 0 16px rgb(125 214 255 / 24%); - - &:hover:not(:disabled) { - transform: translateY(-2px); - color: var(--neon-violet); - border-color: rgb(181 140 255 / 60%); - box-shadow: inset 0 0 22px rgb(181 140 255 / 22%), 0 0 28px rgb(181 140 255 / 38%); - } - - &:active:not(:disabled) { - transform: translateY(0); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } - - @media (max-width: 768px) { - padding: var(--spacing-sm) var(--spacing-lg); - font-size: var(--font-size-sm); - } - - @media (prefers-reduced-motion: reduce) { - transition: none; - - &:hover:not(:disabled) { - transform: none; - } - } - } } diff --git a/frontend/src/components/pet/creation/create-pet-modal/index.tsx b/frontend/src/components/pet/creation/create-pet-modal/index.tsx index 42e87693..31bbeb4a 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.tsx +++ b/frontend/src/components/pet/creation/create-pet-modal/index.tsx @@ -3,6 +3,8 @@ import { useQueryClient } from '@tanstack/react-query'; import { useChainCapabilities, useCreatePet, useFees } from '@shared/core'; import { Tones } from '@constants/tones'; import Icon, { CheckIcon, PawIcon } from '@components/ui/icon'; +import NeonButton from '@components/ui/neon-button'; +import NeonModal from '@components/ui/neon-modal'; import TransactionStatus from '@components/common/transaction-status'; import { useNotifyError } from '@hooks/useNotifyError'; import { useTxErrorToast } from '@hooks/useTxErrorToast'; @@ -96,74 +98,65 @@ const CreatePetModal: React.FC = ({ isOpen, onClose }) => { onClose(); }; - if (!isOpen) return null; - return ( -
    -
    e.stopPropagation()}> -
    -

    - - Create Your First Pet -

    - + + + Create Your First Pet + + } + contentClassName="create-pet-body" + > +

    + Give your pet a unique name and bring it to life! You can only create one pet + initially — breed to grow your collection! +

    + +
    +
    + + setPetName(e.target.value)} + placeholder="Enter pet name..." + maxLength={20} + disabled={isInProgress} + />
    -
    -

    - Give your pet a unique name and bring it to life! You can only create one - pet initially — breed to grow your collection! + {mintCost &&

    Mint cost: {mintCost}

    } + + {/* Creating a pet is fully on-chain (Switchboard VRF + program) and + needs no backend session — gate on wallet connection only, not SIWS auth. */} + + {buttonLabel} + + + {isAwaitingFulfillment && ( +

    + Hang tight — your pet will appear once randomness is revealed.

    + )} +
    -
    -
    - - setPetName(e.target.value)} - placeholder="Enter pet name..." - maxLength={20} - disabled={isInProgress} - /> -
    - - {mintCost &&

    Mint cost: {mintCost}

    } - - {/* Creating a pet is fully on-chain (Switchboard VRF + program) and - needs no backend session — gate on wallet connection only, not SIWS auth. */} - - - {isAwaitingFulfillment && ( -

    - Hang tight — your pet will appear once randomness is revealed. -

    - )} -
    - - {success && ( -
    - - {success} -
    - )} - - + {success && ( +
    + + {success}
    -
    -
    + )} + + + ); }; diff --git a/frontend/tests/components/pet/creation/create-pet-modal.test.tsx b/frontend/tests/components/pet/creation/create-pet-modal.test.tsx index d42a2d38..a8e32d36 100644 --- a/frontend/tests/components/pet/creation/create-pet-modal.test.tsx +++ b/frontend/tests/components/pet/creation/create-pet-modal.test.tsx @@ -20,7 +20,13 @@ const petList = { refetch: vi.fn() }; const capabilities = { isConnected: true, kind: 'solana' }; vi.mock('@shared/core', () => ({ - useAuth: () => ({ isAuthenticated: true, isSigning: false, isVerifying: false, isNonceLoading: false, signAndLogin: vi.fn() }), + useAuth: () => ({ + isAuthenticated: true, + isSigning: false, + isVerifying: false, + isNonceLoading: false, + signAndLogin: vi.fn(), + }), useChainCapabilities: () => capabilities, usePetList: () => petList, useFees: () => ({ @@ -95,7 +101,7 @@ describe('CreatePetModal', () => { it('closes and resets via the close button', async () => { const onClose = renderModal(); - await userEvent.click(screen.getByRole('button', { name: '×' })); + await userEvent.click(screen.getByRole('button', { name: 'Close modal' })); expect(createPet.reset).toHaveBeenCalled(); expect(onClose).toHaveBeenCalledOnce(); From 43f6726d90acd721a7101d52d8717aa6c5323382 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 22:43:42 -0400 Subject: [PATCH 47/48] refactor(frontend): migrate marriage confirm dialog to NeonModal --- frontend/CODE_QUALITY_REFACTOR.md | 40 ++++++++++++++++--- .../interactions/panels/marriage/index.css | 33 +-------------- .../marriage/parts/accept-confirm-dialog.tsx | 36 +++++++++-------- 3 files changed, 55 insertions(+), 54 deletions(-) diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md index 87d3bf8b..d2e7ce5d 100644 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ b/frontend/CODE_QUALITY_REFACTOR.md @@ -41,7 +41,7 @@ session can resume without re-deriving context. | 4 | Decompose `breed` panel into orchestrator + parts | DONE | | 5 | Extract `pet-gallery` cooldown logic into a hook | DONE | | 6 | Design-system adoption — buttons (app-wide) | DONE (6a–6d; 6e optional) | -| 7 | Migrate bespoke modals to NeonModal | IN PROGRESS (7a done) | +| 7 | Migrate bespoke modals to NeonModal | DONE (7a–7c) | | 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | Statuses: `TODO` → `IN PROGRESS` → `DONE` (or `SKIPPED` with reason). @@ -513,7 +513,8 @@ Co-Authored-By: Claude Opus 4.8 --- ## Step 7 — Migrate bespoke modals to NeonModal -**Status:** IN PROGRESS (7a done; 7b, 7c pending) +**Status:** DONE (7a–7c) — all three bespoke modals now use NeonModal (focus-trap, ESC, +`role=dialog`, `aria-modal`, overlay-click). **Goal:** All modals use `NeonModal` (react-modal) for `role="dialog"`, `aria-modal`, Escape-to-close, focus trap, and overlay-click close. @@ -535,10 +536,10 @@ tests use `screen` queries which see the portal, so they pass unchanged. **Sub-steps (one commit each):** - **7a — send-pet-modal (DONE):** see outcome below. - **7b — create-pet-modal (DONE):** same pattern; submit → `NeonButton`. See outcome below. -- **7c — marriage accept-confirm-dialog:** Confirm already a `NeonButton` (6d); wrap in NeonModal, - title "💒 Accept Proposal?", keep ghost Cancel; remove `.marriage-confirm-overlay`/`-dialog`/ - `.confirm-title`/`.confirm-body`/`.confirm-actions` chrome CSS as appropriate. Verify the - marriage test's confirm-flow (`Accept Proposal` text + `/Confirm/` button) still passes. +- **7c — marriage accept-confirm-dialog (DONE):** wrapped in NeonModal (title "💒 Accept + Proposal?"), kept ghost Cancel; removed `.marriage-confirm-overlay`/`-dialog`/`.confirm-title` + chrome, kept `.confirm-body`/`.confirm-actions`/`.confirm-cancel` (added `margin-top` for + spacing). Marriage confirm-flow test passes 9/9 unchanged. **Acceptance (whole step):** keyboard (Esc, tab-trap) + `role=dialog` work; behavior preserved; typecheck + lint + tests pass. @@ -595,6 +596,31 @@ Behavior preserved. Typecheck, lint, and all 272 tests pass. Co-Authored-By: Claude Opus 4.8 ``` +### 7c outcome (done) +- `accept-confirm-dialog.tsx` now renders `` with the body text + + Cancel/Confirm as children. It's mounted only when `pendingAccept` is set, so `isOpen` is + always true while mounted; ESC/overlay/Close all route to `onCancel`. +- `marriage/index.css`: removed `.marriage-confirm-overlay`, `.marriage-confirm-dialog`, + `.confirm-title` (chrome); kept `.confirm-body`/`.confirm-actions` (added `margin-top: 16px`) + and the ghost `.confirm-cancel`. +- **Verified:** no dangling refs; `src` `format:check` clean; `tsc -b` 0; `eslint .` 0; + marriage 9/9; **272/272 pass**. + +**7c commit message:** +``` +refactor(frontend): migrate marriage confirm dialog to NeonModal + +Render the accept-confirm dialog via NeonModal for focus-trap, Escape, and +role=dialog, dropping the hand-rolled overlay/dialog/title chrome. Keep the +ghost Cancel and the NeonButton Confirm; reduce the CSS to body content. + +Completes the bespoke-modal migration. Behavior preserved; typecheck, lint, +and all 272 tests pass. + +Co-Authored-By: Claude Opus 4.8 +``` + --- ## Step 8 — Minor cleanups @@ -634,3 +660,5 @@ Co-Authored-By: Claude Opus 4.8 - 2026-06-22 — fix(shared): resolve 3 pre-existing exhaustive-deps lint warnings - 2026-06-22 — Step 6d — refactor(frontend): migrate remaining action buttons to NeonButton - 2026-06-22 — Step 7a — refactor(frontend): migrate send-pet modal to NeonModal +- 2026-06-22 — Step 7b — refactor(frontend): migrate create-pet modal to NeonModal +- 2026-06-22 — Step 7c — refactor(frontend): migrate marriage confirm dialog to NeonModal diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.css b/frontend/src/components/pet/interactions/panels/marriage/index.css index e7b5c978..559659c0 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.css +++ b/frontend/src/components/pet/interactions/panels/marriage/index.css @@ -329,37 +329,7 @@ color: rgb(125 214 255 / 45%); } -/* Confirm accept modal */ -.marriage-confirm-overlay { - position: fixed; - inset: 0; - background: rgb(0 0 0 / 55%); - display: flex; - align-items: center; - justify-content: center; - z-index: 8000; - padding: 16px; -} - -.marriage-confirm-dialog { - background: rgb(5 13 30 / 97%); - border: 1px solid rgb(0 255 157 / 36%); - border-radius: 12px; - padding: 24px; - max-width: 380px; - width: 100%; - box-shadow: 0 0 40px rgb(0 255 157 / 14%); - display: flex; - flex-direction: column; - gap: 16px; -} - -.confirm-title { - margin: 0; - font-size: 16px; - color: #9effd4; -} - +/* Confirm accept modal — body content only; chrome comes from . */ .confirm-body { margin: 0; font-size: 14px; @@ -371,6 +341,7 @@ display: flex; gap: 10px; justify-content: flex-end; + margin-top: 16px; } .confirm-cancel { diff --git a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx index abb01496..48b035a3 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx @@ -1,5 +1,6 @@ import React from 'react'; import NeonButton from '@components/ui/neon-button'; +import NeonModal from '@components/ui/neon-modal'; import type { PendingAccept } from '../types'; type AcceptConfirmDialogProps = { @@ -20,24 +21,25 @@ const AcceptConfirmDialog: React.FC = ({ onCancel, onConfirm, }) => ( -
    -
    e.stopPropagation()}> -
    💒 Accept Proposal?
    -

    - {pending.proposal.proposerPetName} (# - {pending.proposal.proposerPetId}) will marry your{' '} - {targetPetName(pending.myPetId)} (#{pending.myPetId}). -

    -
    - - - {isAccepting ? 'Accepting...' : '💒 Confirm'} - -
    + +

    + {pending.proposal.proposerPetName} (#{pending.proposal.proposerPetId}) + will marry your {targetPetName(pending.myPetId)} (#{pending.myPetId}). +

    +
    + + + {isAccepting ? 'Accepting...' : '💒 Confirm'} +
    -
    + ); export default AcceptConfirmDialog; From ad01d38b18ce0beee70ef3e69cf9a1ed69c19861 Mon Sep 17 00:00:00 2001 From: tydreamer Date: Mon, 22 Jun 2026 22:52:40 -0400 Subject: [PATCH 48/48] chore: tighten no-explicit-any rule and drop dead CSS --- frontend/CODE_QUALITY_REFACTOR.md | 664 ------------------ frontend/eslint.config.js | 2 +- .../pet/interactions/interactions.css | 18 - 3 files changed, 1 insertion(+), 683 deletions(-) delete mode 100644 frontend/CODE_QUALITY_REFACTOR.md diff --git a/frontend/CODE_QUALITY_REFACTOR.md b/frontend/CODE_QUALITY_REFACTOR.md deleted file mode 100644 index d2e7ce5d..00000000 --- a/frontend/CODE_QUALITY_REFACTOR.md +++ /dev/null @@ -1,664 +0,0 @@ -# Frontend Code-Quality Refactor — Plan & Progress - -A step-by-step refactor of `frontend/src` to fix architectural-consistency issues -surfaced in the code-quality review. Work is done **one step per commit**; the user -commits manually after each step. This file is the single source of truth so any -session can resume without re-deriving context. - -## How to use this doc -1. Pick the first step whose status is `TODO`. -2. Do **only** that step. Keep the diff focused. -3. Verify with the step's acceptance criteria (typecheck + lint + tests as noted). -4. Fill in the step's **Outcome** and **Commit message**, flip status to `DONE`. -5. Hand the commit message to the user; they commit manually. - -## Project conventions (apply to every step) -- **Indentation:** 4 spaces (the app majority; see Step 1). -- **Components:** `kebab-case/index.tsx`, `React.FC`, `type XxxProps`, default export. -- **Parts pattern:** complex panels = slim orchestrator + `parts/` presentational - components (see `panels/battle` and the refactored `panels/marriage` as templates). -- **Hooks:** `useXxx.ts`, camelCase. -- **Verify commands** (run from `frontend/`): - - Typecheck: `node ../node_modules/typescript/bin/tsc -b` - - Lint: `node ../node_modules/eslint/bin/eslint.js ` - - Tests: `node ../node_modules/vitest/vitest.mjs run ` -- **Commit style:** Conventional Commits; trailer - `Co-Authored-By: Claude Opus 4.8 `. - -## Reference templates already in the codebase -- Headless controller: `src/hooks/battle/useBattlePanel.ts` -- Parts decomposition: `src/components/pet/interactions/panels/battle/parts/` -- Recently refactored example: `src/components/pet/interactions/panels/marriage/` - ---- - -## Status overview -| # | Step | Status | -|---|------|--------| -| 1 | Tooling: Prettier + `.editorconfig` + reformat | DONE | -| 2 | Colocate panel CSS (split `overview/index.css`) | DONE | -| 3 | Extract shared `useSpousePet` hook | DONE | -| 4 | Decompose `breed` panel into orchestrator + parts | DONE | -| 5 | Extract `pet-gallery` cooldown logic into a hook | DONE | -| 6 | Design-system adoption — buttons (app-wide) | DONE (6a–6d; 6e optional) | -| 7 | Migrate bespoke modals to NeonModal | DONE (7a–7c) | -| 8 | Minor cleanups (eslint `any`→warn, hex→CSS vars) | TODO | - -Statuses: `TODO` → `IN PROGRESS` → `DONE` (or `SKIPPED` with reason). - ---- - -## Step 1 — Tooling: Prettier + `.editorconfig` + reformat -**Status:** DONE - -**Goal:** Eliminate the 2-space (`ui/neon-*`) vs 4-space (rest) inconsistency and -prevent future drift. - -**Why:** `eslint.config.js` has no Prettier and no `indent` rule; no `.editorconfig` -exists. Nothing enforces style today. - -**Plan:** -- Add Prettier config (4-space, single quotes, trailing commas — match existing majority). -- Add `.editorconfig` (4-space, LF, final newline). -- Wire a `format`/`format:check` script in `frontend/package.json`. -- Run a one-shot reformat across `src/`. This is a **large, whitespace-only diff** — - keep it isolated in its own commit so later steps stay reviewable. - -**Acceptance:** -- `format:check` passes clean. -- Typecheck + lint pass. -- Diff is whitespace/formatting only (no logic changes). - -**Outcome (done):** -- Settings chosen: `tabWidth: 4`, `singleQuote: true`, `semi: true`, - `trailingComma: 'all'`, `printWidth: 100`. -- Added `frontend/.prettierrc.json`, `frontend/.prettierignore`, `frontend/.editorconfig`. -- Added `format` / `format:check` scripts; pinned `prettier` to `2.8.8` in devDependencies - (the version already present in the monorepo store — avoids a fresh download). -- Reformatted all of `src/**/*.{ts,tsx,css}`. The `ui/neon-*` 2-space files are now 4-space; - some long lines rewrapped at width 100. -- **Verified:** `format:check` clean, `tsc -b` 0 errors, `eslint .` 0, **272/272 tests pass**. -- **Lockfile note:** `pnpm add` hit the known Windows EBUSY lock-rename issue - ([[windows-file-lock-renames]]); applied the copy-temp-over workaround so the root - `pnpm-lock.yaml` now records `prettier` as a frontend devDep. The same install also - normalized an unrelated `isomorphic-ws` peer-dep notation (pnpm re-resolution; harmless, - would appear on anyone's next install). -- **Prettier version caveat:** pinned to 2.8.8 (old, 2023) because installing 3.x failed on - the EBUSY lock. Upgrading to Prettier 3.x is a good future follow-up once installs work. - -**Commit message:** -``` -chore(frontend): add prettier + editorconfig and reformat src - -Standardize formatting (4-space, single quotes, semicolons, trailing -commas, printWidth 100) to remove the 2-space/4-space inconsistency between -the neon-* UI components and the rest of the app. Add .prettierrc.json, -.prettierignore, .editorconfig, and format/format:check scripts; pin -prettier to the version already in the monorepo store. - -Whitespace/formatting only — no logic changes. Typecheck, lint, and all -272 tests pass. - -Co-Authored-By: Claude Opus 4.8 -``` - ---- - -## Step 2 — Colocate panel CSS (split `overview/index.css`) -**Status:** DONE - -**Goal:** Each panel owns and imports its own stylesheet; remove cross-component -CSS coupling. - -**Why:** `overview/index.css` is ~1281 lines holding styles for marriage, rename, -train, level-up **and** the hub. `standalone/index.tsx:10` imports a sibling's CSS -(`overview/index.css`) just to style those panels — fragile. `battle/` and `breed/` -already colocate correctly; bring the rest in line. - -**Plan:** -- Identify selectors per panel (`.marriage-*`, `.proposal-*`, `.rename-*`, `.train-*`, - `.level-up-*`, plus shared `.interface`, `.action-controls`, `.picker`, `.field`, - `.success-message`). -- Move panel-specific blocks into colocated `index.css` files imported by each panel. -- Keep genuinely shared/hub styles in `overview/index.css` (or promote shared ones to - `styles/`). Decide: shared form primitives (`.interface`, `.field`, `.picker`) likely - belong in `styles/` so all panels + standalone get them without importing a sibling. -- Remove the `overview/index.css` import from `standalone/index.tsx` once styles resolve - via each panel + shared styles. - -**Acceptance:** -- Each panel route (dashboard hub **and** standalone `/marriage`, `/breed`, etc.) renders - visually unchanged. Verify in-app (the `run`/`verify` skills). -- No component imports another component's `index.css`. -- Typecheck + lint + tests pass. - -**Risk:** CSS regressions are visual; verify each standalone page and the hub. - -**Outcome (done):** -- Split the 1241-line `overview/index.css` into three files (pure move, no rule edits): - - **NEW** `interactions/interactions.css` (403 lines) — shared tokens - (`.dashboard-panel.pet-interactions` `--zi-*`), `.interface`, `.picker`/`.field`, - `.name-input`, `.action-button`, `.action-controls`, `.cancel-button`, `.win-estimate`, - `.transaction-info`, `.interaction-standalone-header`, `.help-text`, messages. - - **NEW** `panels/marriage/index.css` (470 lines) — all `.marriage-*`, `.proposal-*`, - `.sent-proposals-*`, `.accept-*`, `.propose-button`, `.confirm-*`, modal styles. - - `overview/index.css` (368 lines) — now hub-card styles only (`.action-buttons`, - `.breeding-lab-card`, `.battle-arena-card`, `.feature-action-card`, hub buttons). -- Import wiring: - - `overview/index.tsx` now imports `../interactions.css` + `./index.css`. - - `standalone/index.tsx` now imports `interactions.css` (was importing `overview/index.css` - — the fragile sibling coupling is gone). - - `marriage/index.tsx` now imports its own `./index.css`. - - `battle`/`breed` unchanged: they already colocate CSS and get shared primitives from the - wrapper (same load order as before — verified no regression). -- **Verified:** no component imports another's `index.css`; `format:check` clean; `tsc -b` 0; - `eslint .` 0; **272/272 tests pass**; `vite build` succeeds and the built CSS contains the - shared (`action-button`, `win-estimate`), hub (`feature-action-card`), and marriage - (`marriage-card`, `marriage-heartbeat`) selectors. -- **Note:** styles were moved byte-for-byte (then Prettier-normalized), so visual-regression - risk is minimal, but a quick in-app look at the hub + each standalone page is still wise. -- **Dead CSS deferred to Step 8:** `.breed-button`, `.battle-button`, `.transaction-info` are - unused (only `lab-breed-button` is real); kept in `interactions.css` for now to keep this - step a pure move. Added a reminder under Step 8. - -**Commit message:** -``` -refactor(frontend): colocate interaction panel CSS - -Split the 1281-line overview/index.css into three concerns: shared tokens -and form primitives (interactions.css), hub-card styles (overview/index.css), -and marriage-specific styles (panels/marriage/index.css). Wire each surface -to import what it needs; standalone pages no longer import the overview -component's stylesheet. - -Pure move (Prettier-normalized) — no rule changes. Typecheck, lint, all 272 -tests, and a production build pass; built CSS contains the shared, hub, and -marriage selectors. - -Co-Authored-By: Claude Opus 4.8 -``` - ---- - -## Step 3 — Extract shared `useSpousePet` hook -**Status:** DONE - -**Goal:** One spouse-name-by-id lookup hook, reused by marriage and breed. - -**Why:** Duplicated GraphQL lookups: -- `panels/marriage/parts/marriage-card.tsx:13` — `useSpousePet` + `SPOUSE_GQL` -- `panels/breed/index.tsx:38` — `SpouseLabel` + `SPOUSE_NAME_GQL` -Both run `useQuery(['pet', baseURL, chain, id])`. - -**Plan:** -- Add a shared `useSpousePet(chain, id, opts?)` returning `{ name, level }` in - `@shared/core` (next to other pet hooks) — it's now wanted by 2 features, so the - shared package is the right home. Confirm the existing query key shape so caches - dedupe across both call sites. -- Update `marriage-card.tsx` to consume it; drop the local copy + `SPOUSE_GQL`. -- (`breed` itself is migrated in Step 4; this step just lands the hook + marriage swap.) - -**Acceptance:** -- Marriage cards still resolve spouse name/level. -- No behavior change; typecheck + lint + marriage tests pass. - -**Outcome (done):** -- **NEW** `shared/src/hooks/useSpousePet.ts` — `useSpousePet(chain, id, { skip? })` returning - `{ name?, level? }`. Superset of both old copies (selects `id name level`); keeps the - `['pet', baseURL, chain, id]` query key so marriage + breed dedupe. Throws on GraphQL errors - (the marriage copy silently swallowed them — minor improvement). Exported via - `shared/src/hooks/index.ts` → `@shared/core`. -- `marriage/parts/marriage-card.tsx` now imports `useSpousePet` from `@shared/core`; removed the - local hook + `SPOUSE_GQL` + the now-unused `useQuery`/`useApiClient` imports. Call site updated - to the options form: `useSpousePet(chain, spouseId, { skip: Boolean(fromMap) })`. -- Test: added `useSpousePet` to the `@shared/core` mock in `marriage.test.tsx` (the hook is - called unconditionally in `MarriageCard` before its married-check early return). -- **Breed still has its own `SpouseLabel`** — it gets swapped to this hook in Step 4 (kept here - to keep the diff focused on landing the hook + marriage swap). -- **Verified:** `format:check` clean; `tsc -b` (builds shared + frontend) 0; `eslint .` 0; - **272/272 tests pass**. -- **Scope note:** this commit spans both `shared/` and `frontend/`. - -**Commit message:** -``` -refactor: extract shared useSpousePet hook - -Move the spouse-name-by-id GraphQL lookup duplicated in the marriage and -breed panels into a single useSpousePet hook in @shared/core. It returns -{ name, level }, shares the ['pet', baseURL, chain, id] query key so callers -dedupe, and surfaces GraphQL errors. Marriage now consumes the shared hook; -breed is migrated in a follow-up. - -Typecheck, lint, and all 272 tests pass. - -Co-Authored-By: Claude Opus 4.8 -``` - ---- - -## Step 4 — Decompose `breed` panel into orchestrator + parts -**Status:** DONE - -**Goal:** Reduce `panels/breed/index.tsx` (~403 lines) to a slim orchestrator plus -`parts/`, mirroring the marriage refactor. - -**Why:** Same monolith pattern just fixed in marriage: two tabs of state, contract -relative-detection logic, pending-breed logic, and all JSX in one file. - -**Plan:** -- `types.ts`: `BreedTab`, `BreedPanelProps`. -- Consider a headless `useBreedPanel` hook (battle-style) for the contract/relative/ - pending logic if it stays heavy after splitting JSX. -- `parts/`: `breed-tab-bar`, `own-pets-tab`, `with-spouse-tab`, `offspring-name-input`, - `breed-submit` (reuse existing `pending-breed-notice`, `stud-fee-balance`). -- Replace the local `SpouseLabel` with the shared `useSpousePet` from Step 3. -- Move tab-local state into the relevant tab components where it isn't shared. - -**Acceptance:** -- Both tabs (own / with-spouse), relative warning, pending-breed notices, stud fee, - and submit all behave identically. Verify in-app. -- Typecheck + lint + any breed tests pass. - -**Outcome (done):** -- `breed/index.tsx`: **466 → 280 lines**. Now a slim orchestrator owning state, effects, - contract reads (relative detection), pending-breed checks, and the mutation; delegates all - rendering. -- `types.ts`: `BreedTab`, `BreedPanelProps`. -- New `parts/`: `breed-tab-bar.tsx`, `own-pets-tab.tsx`, `with-spouse-tab.tsx`, - `spouse-label.tsx` (now uses the shared `useSpousePet` from Step 3 — the old local - `SpouseLabel` + `SPOUSE_NAME_GQL` are gone). -- Moved the two existing flat sub-files into `parts/` (git mv): `pending-breed-notice.tsx`, - `stud-fee-balance.tsx` — breed now matches the battle/marriage `parts/` convention. -- Tab-local state stays in the orchestrator (it feeds the relative-check contract reads, - `canSubmit`, and the success reset), so the tabs are controlled via props — same as the - marriage tabs. -- Tests: updated `breed.test.tsx` mock paths to `parts/...` and added `useSpousePet` to the - `@shared/core` mock (returns no name so `SpouseLabel` falls back to `#id`). -- **Verified:** `format:check` clean; `tsc -b` 0; `eslint .` 0; breed tests 11/11; - **272/272 tests pass**. -- **Optional future refinement:** index.tsx is still ~280 lines because all logic lives there - (intentional, matches marriage). If a thinner view is wanted later, extract a headless - `useBreedPanel` hook (battle-style) — deliberately deferred to keep this diff focused/low-risk. - -**Commit message:** -``` -refactor(frontend): decompose breed panel into parts - -Split the 466-line breed/index.tsx into a slim orchestrator plus parts -(tab bar, own-pets tab, with-spouse tab, spouse label) and move the existing -pending-breed-notice / stud-fee-balance into parts/, matching the -battle/marriage convention. The spouse label now uses the shared useSpousePet -hook, removing the last duplicated spouse-name GraphQL lookup. - -Behavior preserved; breed tests updated for the new module paths. Typecheck, -lint, and all 272 tests pass. - -Co-Authored-By: Claude Opus 4.8 -``` - ---- - -## Step 5 — Extract `pet-gallery` cooldown logic into a hook -**Status:** DONE - -**Goal:** Move readiness/cooldown computation out of the view. - -**Why:** `pet-gallery/index.tsx:51` runs a manual 1s `setInterval` re-render tick and -inlines readiness math (`!isPetReady(BigInt(p.readyAt)) || ...`) duplicated at lines -~53–55 and ~206–208. - -**Plan:** -- Add `usePetCooldowns(pets)` (or similar) returning per-pet readiness flags + labels - and owning the tick interval. Place in `src/hooks/` (or `@shared/core` if reused). -- View consumes the hook and just renders. - -**Acceptance:** -- Cooldown countdowns still tick live; ready/on-cooldown states unchanged. -- Typecheck + lint pass. - -**Outcome (done):** -- **NEW** `src/hooks/usePetCooldowns.ts` — `usePetCooldowns(pets)` returns `{ anyCooldown, - statusFor }`. Owns the 1s `setInterval` tick (only while `anyCooldown`) and a `statusFor(pet)` - helper returning `{ onCooldown, battleReady, battleOnCooldown, breedOnCooldown, trainOnCooldown, - battleLabel, breedLabel, trainLabel }`. The readiness math (previously inlined 3×) lives here once. -- `pet-gallery/index.tsx`: removed the inline `anyCooldown`/tick effect and the - `isPetReady`/`getTimeUntilReady` imports; now calls `usePetCooldowns(pets)` and reads - `cd = statusFor(pet)` once per card. The status block and Send button consume `cd.*` instead of - recomputing `isPetReady(BigInt(...))` inline. -- **Verified:** `format:check` clean; `tsc -b` 0; `eslint .` 0; **272/272 tests pass** (incl. - `pet-gallery.test.tsx` — its `@shared/core` mock already provides `isPetReady`/`getTimeUntilReady`, - now reached via the hook). -- **Note:** kept the hook frontend-local in `src/hooks/` since pet-gallery is its only consumer; - promote to `@shared/core` if another surface needs it. - -**Commit message:** -``` -refactor(frontend): extract usePetCooldowns hook from pet-gallery - -Move the per-pet readiness math (duplicated three times in the gallery view) -and the 1s countdown tick into a usePetCooldowns hook that returns a -statusFor(pet) helper. The view now reads cd.* flags/labels instead of -recomputing isPetReady(BigInt(...)) inline, and the tick lives in the hook. - -Behavior unchanged. Typecheck, lint, and all 272 tests pass. - -Co-Authored-By: Claude Opus 4.8 -``` - ---- - -## Step 6 — Design-system adoption — buttons (app-wide) -**Status:** IN PROGRESS - -**Decision (user, 2026-06-22):** **Adopt neon-\* app-wide.** Migrate buttons to `NeonButton` -and (Step 7) modals to `NeonModal`. - -**Why:** `ui/neon-*` exists but was used **only in `wallet/`**. Elsewhere: raw -`