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/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/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/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()) 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 714fcb03..92a1e8d6 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 { @@ -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/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); +}); 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/.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/eslint.config.js b/frontend/eslint.config.js index 6c1fdcf5..11591b40 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -34,7 +34,7 @@ export default tseslint.config( 'import/no-duplicates': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-empty-object-type': 'off', 'react-refresh/only-export-components': 'off', 'prefer-const': 'error', diff --git a/frontend/package.json b/frontend/package.json index 0c7eafe6..9c4ebf20 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,26 +11,25 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "lint:check": "eslint . --max-warnings 0", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\"", "preview": "pnpm vite preview", "test": "vitest run", "test:watch": "vitest", "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 +45,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", @@ -62,6 +60,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.4.0", "jsdom": "^29.1.1", + "prettier": "2.8.8", "typescript": "~5.8.3", "typescript-eslint": "^8.44.0", "vite": "^7.1.7", diff --git a/frontend/src/App.css b/frontend/src/App.css index 7671d97a..4df8f9d7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,9 +1,9 @@ #root { - width: 100%; - max-width: none; - margin: 0; - padding: 0; - text-align: center; - height: 100vh; - overflow: hidden; + width: 100%; + max-width: none; + margin: 0; + padding: 0; + text-align: center; + height: 100vh; + overflow: hidden; } diff --git a/frontend/src/chains/ethereum/wagmi.ts b/frontend/src/chains/ethereum/wagmi.ts index b6f57c08..7640cda6 100644 --- a/frontend/src/chains/ethereum/wagmi.ts +++ b/frontend/src/chains/ethereum/wagmi.ts @@ -14,6 +14,6 @@ export const wagmiConfig = createConfig({ connectors: [injected()], multiInjectedProviderDiscovery: false, transports: Object.fromEntries( - allChains.map((chain) => [chain.id, http(chain.rpcUrls.default.http[0])]) + allChains.map((chain) => [chain.id, http(chain.rpcUrls.default.http[0])]), ), }); diff --git a/frontend/src/chains/solana/anchor-wallet.tsx b/frontend/src/chains/solana/anchor-wallet.tsx index 459851ac..9f78a683 100644 --- a/frontend/src/chains/solana/anchor-wallet.tsx +++ b/frontend/src/chains/solana/anchor-wallet.tsx @@ -12,7 +12,9 @@ export const SolanaAnchorWallet = ({ children }: { children: ReactNode }) => { const { connection } = useConnection(); const adapterWallet = useAnchorWallet(); const dynamicSolanaWallet = useDynamicSolanaWallet(); - const [dynamicSigningWallet, setDynamicSigningWallet] = useState(null); + const [dynamicSigningWallet, setDynamicSigningWallet] = useState( + null, + ); useEffect(() => { let cancelled = false; @@ -56,16 +58,23 @@ export const SolanaAnchorWallet = ({ children }: { children: ReactNode }) => { const signingWallet = adapterWallet ?? dynamicSigningWallet; - const programId = useMemo( - () => parseProgramId(import.meta.env.VITE_CRYPTOPETS_PROGRAM_ID), - [] + const programId = useMemo(() => parseProgramId(import.meta.env.VITE_CRYPTOPETS_PROGRAM_ID), []); + + const idlAddress = useMemo( + () => parseProgramId(import.meta.env.VITE_CRYPTOPETS_IDL_ADDRESS), + [], ); return ( - + {children} ); -} +}; export default SolanaAnchorWallet; diff --git a/frontend/src/chains/solana/auth-signer.tsx b/frontend/src/chains/solana/auth-signer.tsx index df7d5895..7a350040 100644 --- a/frontend/src/chains/solana/auth-signer.tsx +++ b/frontend/src/chains/solana/auth-signer.tsx @@ -1,7 +1,10 @@ import { useEffect } from 'react'; import { useWallet } from '@solana/wallet-adapter-react'; import { setSolanaAuthSigner, type SolanaAuthSigner as SharedSolanaAuthSigner } from '@shared/core'; -import { useDynamicSolanaWallet, type DynamicSolanaWalletLike } from '@chains/solana/useDynamicSolanaWallet'; +import { + useDynamicSolanaWallet, + type DynamicSolanaWalletLike, +} from '@chains/solana/useDynamicSolanaWallet'; /** * Dynamic's `SolanaWallet.signMessage(string)` is the generic Dynamic API — it expects a string @@ -9,7 +12,9 @@ import { useDynamicSolanaWallet, type DynamicSolanaWalletLike } from '@chains/so * `getSigner()` to reach the canonical `ISolana.signMessage(Uint8Array)` which returns * `{ signature: Uint8Array }`. Coercion to base58 is handled downstream by `signatureAuthCodec`. */ -const signerFromDynamicWallet = async (wallet: DynamicSolanaWalletLike): Promise => { +const signerFromDynamicWallet = async ( + wallet: DynamicSolanaWalletLike, +): Promise => { if (typeof wallet.getSigner !== 'function') { return null; } @@ -34,7 +39,7 @@ const signerFromDynamicWallet = async (wallet: DynamicSolanaWalletLike): Promise throw new Error('Dynamic Solana signer returned an unexpected signature shape'); }, }; -} +}; /** * Registers Solana signing for {@link AuthProvider} / `signAndLogin`: @@ -76,6 +81,6 @@ export const SolanaAuthSigner = () => { }, [publicKey, signMessage, dynamicSolanaWallet]); return null; -} +}; export default SolanaAuthSigner; diff --git a/frontend/src/chains/solana/provider.tsx b/frontend/src/chains/solana/provider.tsx index 6fe3b215..2624c4ec 100644 --- a/frontend/src/chains/solana/provider.tsx +++ b/frontend/src/chains/solana/provider.tsx @@ -18,10 +18,7 @@ export const SolanaWalletProvider: React.FC = ({ }) => { const networkConfig = SOLANA_NETWORKS.find((n) => n.name === network) || SOLANA_NETWORKS[0]; - const wallets = useMemo( - () => [new PhantomWalletAdapter(), new SolflareWalletAdapter()], - [] - ); + const wallets = useMemo(() => [new PhantomWalletAdapter(), new SolflareWalletAdapter()], []); return ( diff --git a/frontend/src/chains/solana/useDynamicSolanaWallet.ts b/frontend/src/chains/solana/useDynamicSolanaWallet.ts index d7ef1402..278e6566 100644 --- a/frontend/src/chains/solana/useDynamicSolanaWallet.ts +++ b/frontend/src/chains/solana/useDynamicSolanaWallet.ts @@ -15,7 +15,7 @@ export type DynamicSolanaWalletLike = { }; /** Resolves the active Dynamic Solana wallet (user wallets list, then primary). */ -export const useDynamicSolanaWallet = (): DynamicSolanaWalletLike | null => { +export const useDynamicSolanaWallet = (): DynamicSolanaWalletLike | null => { const { primaryWallet } = useDynamicContext(); const userWallets = useUserWallets(); @@ -30,4 +30,4 @@ export const useDynamicSolanaWallet = (): DynamicSolanaWalletLike | null => { } return null; }, [userWallets, primaryWallet]); -} +}; diff --git a/frontend/src/components/common/auth-action-button/index.tsx b/frontend/src/components/common/auth-action-button/index.tsx index dce11537..71434d54 100644 --- a/frontend/src/components/common/auth-action-button/index.tsx +++ b/frontend/src/components/common/auth-action-button/index.tsx @@ -1,8 +1,14 @@ import React from 'react'; import { useAuth } from '@shared/core'; +import NeonButton, { type NeonButtonProps } from '@components/ui/neon-button'; -type Props = React.ButtonHTMLAttributes; +type Props = NeonButtonProps; +/** + * Primary action button that gates on auth: when signed out it becomes a + * "Sign in to Play" button driving the wallet sign-in flow; when signed in it + * renders the given action. Styled via the shared (tone/size props). + */ const AuthActionButton: React.FC = ({ onClick, disabled, children, ...rest }) => { const { isAuthenticated, signAndLogin, isSigning, isVerifying, isNonceLoading } = useAuth(); @@ -11,19 +17,19 @@ const AuthActionButton: React.FC = ({ onClick, disabled, children, ...res const signingLabel = isNonceLoading ? 'Getting nonce…' : isSigning - ? 'Check your wallet…' - : 'Verifying…'; + ? 'Check your wallet…' + : 'Verifying…'; return ( - + ); } return ( - + ); }; diff --git a/frontend/src/components/common/dashboard-panel/index.css b/frontend/src/components/common/dashboard-panel/index.css index ebb67447..0d5894a5 100644 --- a/frontend/src/components/common/dashboard-panel/index.css +++ b/frontend/src/components/common/dashboard-panel/index.css @@ -168,17 +168,13 @@ display: flex; align-items: center; justify-content: center; - box-shadow: - inset 0 0 12px rgb(125 214 255 / 14%), - 0 0 10px rgb(125 214 255 / 18%); + box-shadow: inset 0 0 12px rgb(125 214 255 / 14%), 0 0 10px rgb(125 214 255 / 18%); &:hover:not(:disabled) { color: var(--neon-violet); border-color: rgb(181 140 255 / 42%); transform: translateY(-1px); - box-shadow: - inset 0 0 16px rgb(181 140 255 / 18%), - 0 0 16px rgb(181 140 255 / 28%); + box-shadow: inset 0 0 16px rgb(181 140 255 / 18%), 0 0 16px rgb(181 140 255 / 28%); } &:disabled { diff --git a/frontend/src/components/common/dashboard-panel/index.tsx b/frontend/src/components/common/dashboard-panel/index.tsx index 34b44462..2bcf91ef 100644 --- a/frontend/src/components/common/dashboard-panel/index.tsx +++ b/frontend/src/components/common/dashboard-panel/index.tsx @@ -40,7 +40,12 @@ const DashboardPanel: React.FC = ({
{back != null ? ( - ) : 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 02a27f69..4f91d11c 100644 --- a/frontend/src/components/layout/index.css +++ b/frontend/src/components/layout/index.css @@ -1,268 +1,289 @@ /* 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; -.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; - } + @media (max-width: 768px) { + max-width: 100%; + justify-content: center; + } } .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; - - & > * { + flex: 1; + margin-block-start: var(--main-header-offset); width: 100%; - max-width: none; - min-height: 0; - min-width: 0; - } + box-sizing: border-box; + margin-inline: 0; + overflow-y: auto; + overflow-x: hidden; - & > .dashboard-panel { - flex: 1 1 auto; - min-height: 0; - display: flex; - } + /* Landing: gap under fixed header can be painted by hero section. */ + &:has(.landing) { + margin-block-start: 0; + background-color: #070a14; + } } -/* 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; +.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; - & > .dashboard-panel.pet-interactions { - & .surface { - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--neon-cyan) transparent; + & > * { + width: 100%; + max-width: none; + min-height: 0; + min-width: 0; } - & .surface::-webkit-scrollbar { - width: 6px; + & > .dashboard-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; } +} - & .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%); - } +/* 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; +} - & .action-buttons { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-auto-rows: min-content; - align-items: start; - flex: 0 0 auto; +/* 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; } - & .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); - } - - /* 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; - } - - & .action-buttons { - display: grid; - flex: 0 0 auto; - grid-template-columns: repeat(2, minmax(0, 1fr)); - } + .main-content { + padding: var(--spacing-sm) var(--spacing-md); + gap: var(--spacing-sm); } - & > .dashboard-panel.pet-collection { - flex: 1 1 auto; - width: 100%; - min-height: 480px; + /* 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 + 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; + } } - } } - @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..5e8b19c4 100644 --- a/frontend/src/components/pet/collection/pet-gallery/index.css +++ b/frontend/src/components/pet/collection/pet-gallery/index.css @@ -42,32 +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; @@ -101,18 +75,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 +90,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 +111,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 +168,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) { @@ -205,42 +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; @@ -331,10 +293,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 +354,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 +375,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 +389,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 +404,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 +438,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 +516,6 @@ text-shadow: 0 0 6px rgb(181 140 255 / 28%); } - .pet-status { padding: 0 12px; margin-block-start: var(--spacing-xs); @@ -579,9 +542,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 +578,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 +595,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%); } } @@ -658,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 1bf601e4..877eed51 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, @@ -28,10 +26,12 @@ 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'; import { useNotifyError } from '@hooks/useNotifyError'; +import { usePetCooldowns } from '@hooks/usePetCooldowns'; import './index.css'; const PetGallery: React.FC = () => { @@ -40,26 +40,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'); @@ -79,7 +69,12 @@ const PetGallery: React.FC = () => { return ( Your Pet Collection} + title={ + <> + + Your Pet Collection + + } description="Connect your wallet to view your pets" /> ); @@ -88,7 +83,12 @@ const PetGallery: React.FC = () => { return ( <> Your Pets} + title={ + <> + + Your Pets + + } actions={ +
)} @@ -123,119 +126,174 @@ const PetGallery: React.FC = () => { - - - - - + + + + + + + + + + + + + + +

Awaken your first companion

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

- + setCreateModalOpen(true)}> + + Create your first pet + )} {!loading && !error && pets.length > 0 && (
- {pets.map((pet) => ( -
-
-
- {getRarityName(pet.rarity)} -
-
{getPetElement(pet.dna)}
- {getPetSkill(pet.speciesId) ? ( -
- {getPetSkill(pet.speciesId)?.name} + {pets.map((pet) => { + const cd = statusFor(pet); + return ( +
+
+
+ {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))} -
- )} - {pet.breedReadyAt != null && !isPetReady(BigInt(pet.breedReadyAt)) && ( -
- 🥚 Breed ready in {getTimeUntilReady(BigInt(pet.breedReadyAt))} +
+ 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.trainReadyAt != null && !isPetReady(BigInt(pet.trainReadyAt)) && ( -
- 💪 Train ready in {getTimeUntilReady(BigInt(pet.trainReadyAt))} -
+
+ +
+ {Object.entries(getPetProperties(pet)).map( + ([key, value]) => ( +
+ + {getPropertyEmoji(key)} + + {value} +
+ ), )}
- )} -
- + {cd.onCooldown && ( +
+ {cd.battleOnCooldown && ( +
+ ⚔️ Battle ready in {cd.battleLabel} +
+ )} + {cd.breedOnCooldown && ( +
+ 🥚 Breed ready in {cd.breedLabel} +
+ )} + {cd.trainOnCooldown && ( +
+ 💪 Train ready in {cd.trainLabel} +
+ )} +
+ )} + +
+ +
-
- ))} + ); + })}
)} 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..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,112 +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 { @@ -140,9 +42,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%); @@ -164,53 +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 1bf5788e..31bbeb4a 100644 --- a/frontend/src/components/pet/creation/create-pet-modal/index.tsx +++ b/frontend/src/components/pet/creation/create-pet-modal/index.tsx @@ -1,14 +1,11 @@ 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 NeonButton from '@components/ui/neon-button'; +import NeonModal from '@components/ui/neon-modal'; 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'; @@ -42,7 +39,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, }); @@ -57,12 +62,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) { @@ -93,65 +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!

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

Mint cost: {mintCost}

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

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

- )} -
- - {success && ( -
- - {success} -
- )} - - -
+ {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. +

+ )}
-
+ + {success && ( +
+ + {success} +
+ )} + + + ); }; diff --git a/frontend/src/components/pet/interactions/interactions.css b/frontend/src/components/pet/interactions/interactions.css new file mode 100644 index 00000000..3b6e9274 --- /dev/null +++ b/frontend/src/components/pet/interactions/interactions.css @@ -0,0 +1,318 @@ +/* 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; +} + +.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; + } +} + +/* Layout only — children (NeonButton / .cancel-button) style themselves. */ +.action-controls { + display: flex; + gap: 12px; + justify-content: center; + + & button { + @media (max-width: 768px) { + width: 100%; + max-width: 200px; + } + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: center; + } +} + +/* Secondary / ghost action (e.g. battle Cancel) — not a NeonButton tone. */ +.cancel-button { + 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; + border-color: var(--neon-border-strong); + transform: translateY(-1px); + box-shadow: 0 0 12px rgb(125 214 255 / 22%); + } +} diff --git a/frontend/src/components/pet/interactions/overview/index.css b/frontend/src/components/pet/interactions/overview/index.css index 2b5a4c55..842a3570 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; - } - - &.interaction-standalone:has(.battle-setup) { - max-width: 760px; - } - - .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; @@ -286,34 +102,9 @@ } } -.lab-breed-button { +/* Hub card action button — layout only; colour/glow come from . */ +.hub-action { flex: 0 0 auto; - border: 1px solid rgb(0 255 157 / 48%); - border-radius: 2px; - padding: 10px 12px; - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: #9effd4; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - cursor: pointer; - transition: all 0.25s ease; - box-shadow: - inset 0 0 12px rgb(0 255 157 / 14%), - 0 0 12px rgb(0 255 157 / 20%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(0 255 157 / 64%); - box-shadow: - inset 0 0 18px rgb(0 255 157 / 22%), - 0 0 18px rgb(0 255 157 / 32%); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - } } .battle-arena-card { @@ -400,15 +191,17 @@ width: 30px; height: 30px; border-radius: 50%; - background: radial-gradient(circle, rgb(255 110 196 / 35%), rgb(255 110 196 / 8%) 70%); + background: radial-gradient( + circle, + rgb(255 110 196 / 35%), + rgb(255 110 196 / 8%) 70% + ); border: 1px solid rgb(255 110 196 / 60%); display: flex; align-items: center; justify-content: center; color: var(--neon-magenta); - box-shadow: - inset 0 0 10px rgb(255 110 196 / 24%), - 0 0 14px rgb(255 110 196 / 32%); + box-shadow: inset 0 0 10px rgb(255 110 196 / 24%), 0 0 14px rgb(255 110 196 / 32%); } .vs { @@ -422,29 +215,6 @@ grid-template-columns: 1fr; } } - - .start-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(255 110 196 / 48%); - border-radius: 2px; - color: #ff9ad6; - padding: 10px 12px; - font-weight: 700; - 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%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(255 110 196 / 70%); - box-shadow: - inset 0 0 18px rgb(255 110 196 / 22%), - 0 0 18px rgb(255 110 196 / 36%); - } - } } .feature-action-card { @@ -479,800 +249,4 @@ padding: 10px; text-align: left; } - - .levelup-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(181 140 255 / 50%); - border-radius: 2px; - color: var(--neon-violet); - padding: 10px 12px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - cursor: pointer; - box-shadow: - inset 0 0 12px rgb(181 140 255 / 16%), - 0 0 12px rgb(181 140 255 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(181 140 255 / 70%); - box-shadow: - inset 0 0 18px rgb(181 140 255 / 24%), - 0 0 18px rgb(181 140 255 / 36%); - } - } - - .train-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(255 181 67 / 50%); - border-radius: 2px; - color: #ffd07b; - padding: 10px 12px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - cursor: pointer; - box-shadow: - inset 0 0 12px rgb(255 181 67 / 16%), - 0 0 12px rgb(255 181 67 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(255 181 67 / 70%); - box-shadow: - inset 0 0 18px rgb(255 181 67 / 24%), - 0 0 18px rgb(255 181 67 / 36%); - } - } - - .marriage-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(232 175 188 / 50%); - border-radius: 2px; - color: #f0d5de; - padding: 10px 12px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - cursor: pointer; - box-shadow: - inset 0 0 12px rgb(232 175 188 / 16%), - 0 0 12px rgb(232 175 188 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(232 175 188 / 70%); - box-shadow: - inset 0 0 18px rgb(232 175 188 / 24%), - 0 0 18px rgb(232 175 188 / 36%); - } - } - - .changename-button { - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - border: 1px solid rgb(125 214 255 / 50%); - border-radius: 2px; - color: var(--neon-cyan); - padding: 10px 12px; - font-weight: 700; - letter-spacing: 0.7px; - text-transform: uppercase; - cursor: pointer; - box-shadow: - inset 0 0 12px rgb(125 214 255 / 16%), - 0 0 12px rgb(125 214 255 / 22%); - - &:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgb(125 214 255 / 70%); - box-shadow: - inset 0 0 18px rgb(125 214 255 / 24%), - 0 0 18px rgb(125 214 255 / 36%); - } - } -} - -.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 1ed99bb8..8083a21c 100644 --- a/frontend/src/components/pet/interactions/overview/index.tsx +++ b/frontend/src/components/pet/interactions/overview/index.tsx @@ -19,8 +19,16 @@ import { RENAME_PATH, } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; -import Icon, { BattleIcon, EggIcon, LevelUpIcon, MarriageIcon, QuillIcon, TrainIcon } from '@components/ui/icon'; +import Icon, { + BattleIcon, + EggIcon, + LevelUpIcon, + MarriageIcon, + QuillIcon, + TrainIcon, +} from '@components/ui/icon'; import DashboardPanel from '@components/common/dashboard-panel'; +import NeonButton from '@components/ui/neon-button'; import BattlePanel from '@components/pet/interactions/panels/battle'; import BreedPanel from '@components/pet/interactions/panels/breed'; import LevelUpPanel from '@components/pet/interactions/panels/level-up'; @@ -28,15 +36,23 @@ 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. */ -const parseActionParam = (raw: string | undefined): InteractionAction | null => { +const parseActionParam = (raw: string | undefined): InteractionAction | null => { if (!raw) return null; if (raw === 'rename') return 'changename'; - if (raw === 'breed' || raw === 'battle' || raw === 'levelup' || raw === 'train' || raw === 'marriage') return raw; + if ( + raw === 'breed' || + raw === 'battle' || + raw === 'levelup' || + raw === 'train' || + raw === 'marriage' + ) + return raw; return null; -} +}; /** * Dashboard interactions hub (`/main`). @@ -55,9 +71,10 @@ const PetInteractions: React.FC = () => { const activeChainKind = capabilities.activeKind; const fees = useFees(); - const trainFeeLabel = fees.trainFee != null - ? `From ${fees.formatAmount(fees.trainFee)} — cost scales with level.` - : "Cost scales with the pet's level."; + const trainFeeLabel = + fees.trainFee != null + ? `From ${fees.formatAmount(fees.trainFee)} — cost scales with level.` + : "Cost scales with the pet's level."; // Preview an on-chain rival for the Battle Arena card (opponents come from // the roster, not a second owned pet). @@ -73,7 +90,12 @@ const PetInteractions: React.FC = () => { return ( Pet Interactions} + title={ + <> + + Pet Interactions + + } description="Connect your wallet to interact with your pets" /> ); @@ -84,7 +106,12 @@ const PetInteractions: React.FC = () => { Pet Interactions} + title={ + <> + + Pet Interactions + + } >
@@ -97,7 +124,12 @@ const PetInteractions: React.FC = () => { if (pets.length === 0) { return ( Pet Interactions} + title={ + <> + + Pet Interactions + + } description="You don't have any pets yet." helpText="Go to the dashboard and create your first pet." /> @@ -110,10 +142,10 @@ const PetInteractions: React.FC = () => { const previewOpponent = opponents.length > 0 ? [...opponents].sort( - (a, b) => - Math.abs(a.level - (previewParentA?.level ?? a.level)) - - Math.abs(b.level - (previewParentA?.level ?? b.level)), - )[0] + (a, b) => + Math.abs(a.level - (previewParentA?.level ?? a.level)) - + Math.abs(b.level - (previewParentA?.level ?? b.level)), + )[0] : undefined; const availableBattles = Math.min(3, readyPets.length > 0 ? 3 : 0); // Battle only needs one ready pet — the opponent comes from the on-chain roster. @@ -123,69 +155,118 @@ const PetInteractions: React.FC = () => { Pet Interactions} + title={ + <> + + Pet Interactions + + } > {!action && (
-
Breeding Lab
+
+ + Breeding Lab +
- {previewParentA?.name ?? 'Parent A'} - {previewParentA ? `Lv.${previewParentA.level}` : 'Select'} + + {previewParentA?.name ?? 'Parent A'} + + + {previewParentA ? `Lv.${previewParentA.level}` : 'Select'} + +
+
+
-
- {previewParentB?.name ?? 'Parent B'} - {previewParentB ? `Lv.${previewParentB.level}` : 'Select'} + + {previewParentB?.name ?? 'Parent B'} + + + {previewParentB ? `Lv.${previewParentB.level}` : 'Select'} +
- +
- 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'} +
-
+
- +
-
Level Up
+
+ + Level Up +
Boost your pet stats by leveling up. @@ -194,51 +275,63 @@ 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.'}
- +
-
Training Ground
+
+ + Training Ground +
Train your pet for an instant XP boost.
{trainFeeLabel}
- +
-
Marriage
+
+ + Marriage +
Marry two pets to unlock cross-owner breeding.
Propose, accept, or divorce.
- +
-
Change Name
+
+ + Change Name +
Rename your pet. @@ -247,14 +340,15 @@ const PetInteractions: React.FC = () => { ? `Requires level ${capabilities.renameMinLevel} or higher.` : 'Pick a new identity for your companion.'}
- +
)} 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 28b9d50a..d3bbdfb1 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 { @@ -15,12 +18,36 @@ 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); } @@ -35,55 +62,84 @@ .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; @@ -92,7 +148,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%); } } } @@ -106,20 +162,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; } } @@ -140,16 +228,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 { @@ -159,7 +247,7 @@ } .slot-name { - font-size: 12px; + font-size: 13px; font-weight: 700; color: var(--zi-text); overflow: hidden; @@ -176,17 +264,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; } } @@ -257,41 +345,114 @@ } } -.battle-picker-strip { +/* 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: 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; + /* Stretch to fill the battle-setup height; lanes scroll inside their column. */ + flex: 1 1 auto; + min-height: 0; +} - .battle-picker-card { - flex: 0 0 min(168px, calc(100% - 12px)); - max-width: calc(100% - 12px); - } +/* 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; + /* 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 { + 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-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-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-stage .battle-picker-section[aria-label='Opponents'] .section-title { + color: #ff9ad6; +} + +/* The header stays pinned while the card list below it scrolls. */ +.battle-stage .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; + /* 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; 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; +} + +/* 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; + } + + .battle-center { + order: -1; + } + + .battle-picker-strip, + .battle-opponent-grid { + flex: 0 0 auto; + max-height: clamp(180px, 30vh, 360px); } } @@ -329,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%); } } @@ -357,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%); } @@ -471,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); @@ -508,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); @@ -560,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 { @@ -576,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 { @@ -605,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) { @@ -703,11 +850,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%); } } @@ -720,11 +867,13 @@ @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%); } } @@ -751,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) { @@ -779,13 +932,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 { 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 2b8b9f94..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 @@ -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 = { @@ -70,147 +71,196 @@ const BattleSetup: React.FC = ({
{!isStandaloneView && ( <> -

Battle Pets

+

+ + Battle Pets +

{subtitle}

)} -
-
- 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 ( + + ); + })} +
+ )} +
); 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/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/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 = ({ +
+ )} +
+ ); + } + + 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..460815af 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,47 +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 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(); @@ -82,11 +55,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]); @@ -108,14 +88,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, @@ -156,8 +148,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], @@ -174,8 +169,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' @@ -188,9 +185,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, + }); } }; @@ -198,165 +204,51 @@ const BreedPanel: React.FC = ({ isStandaloneView = true }) => { <>

{!isStandaloneView && ( -

Breed Pets

+

+ + Breed Pets +

)} - {/* 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} - /> -
- - )} -
+ )} + +
= ({ isStandaloneView = true }) => {
- {breed.isAwaitingFulfillment && ( -

{AWAITING_HINT}

- )} + {breed.isAwaitingFulfillment &&

{AWAITING_HINT}

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

Transaction: {hashHint}

- )} + {hashHint &&

Transaction: {hashHint}

} 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/parts/pending-breed-notice.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/pending-breed-notice.tsx new file mode 100644 index 00000000..0c4285c7 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/pending-breed-notice.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { usePendingBreed, usePendingSolanaBreed } from '@shared/core'; +import { useTxErrorToast } from '@hooks/useTxErrorToast'; + +type PendingBreedNoticeProps = { + /** Parent to check for an unresolved breed; null/empty renders nothing. */ + 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. 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 pending = usePendingBreed(petId); + const solanaPending = usePendingSolanaBreed(checkSolana); + 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. + {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 && ( +
+ +
+ )} +
+ ); + } + + const busy = pending.settle.isPending || pending.cancel.isPending; + 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. +

+
+ + +
+
+ ); +}; + +export default PendingBreedNotice; 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/parts/stud-fee-balance.tsx b/frontend/src/components/pet/interactions/panels/breed/parts/stud-fee-balance.tsx new file mode 100644 index 00000000..ee0eee15 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/breed/parts/stud-fee-balance.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useChainCapabilities, useStudFees } from '@shared/core'; +import { useTxErrorToast } from '@hooks/useTxErrorToast'; + +const LAMPORTS_PER_SOL = 1_000_000_000n; + +const formatSol = (lamports: bigint): string => { + 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/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/pending-breed-notice.tsx b/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx deleted file mode 100644 index 80336577..00000000 --- a/frontend/src/components/pet/interactions/panels/breed/pending-breed-notice.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { usePendingBreed } from '@shared/core'; -import { useTxErrorToast } from '@hooks/useTxErrorToast'; - -type PendingBreedNoticeProps = { - /** Parent to check for an unresolved breed; null/empty renders nothing. */ - petId?: string; - /** Friendly label for the pet (e.g. its name); falls back to the id. */ - label?: string; -}; - -/** - * 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. - */ -const PendingBreedNotice: React.FC = ({ petId, label }) => { - const pending = usePendingBreed(petId); - useTxErrorToast(pending.settle.error ?? pending.cancel.error); - - if (!pending.isPending) return null; - - const who = label ?? `#${petId}`; - const busy = pending.settle.isPending || pending.cancel.isPending; - - 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. -

-
- - -
-
- ); -}; - -export default PendingBreedNotice; 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/src/components/pet/interactions/panels/level-up/index.tsx b/frontend/src/components/pet/interactions/panels/level-up/index.tsx index 1a314408..77342e73 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,15 +25,23 @@ 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(); }; - const { mutate, isPending, error: hookError, reset, lifecycle } = useLevelUpPet({ + const { + mutate, + isPending, + error: hookError, + reset, + lifecycle, + } = useLevelUpPet({ onSuccess: handleLevelUpComplete, }); const readyPets = useMemo(() => getReadyPetsUnified(pets), [pets]); @@ -68,10 +77,10 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true }) const buttonLabel = isPending ? 'Leveling Up...' : levelUpCost - ? `Level Up (${levelUpCost})` - : levelUpFee - ? `Level Up (from ${levelUpFee.amount} ${levelUpFee.symbol})` - : 'Level Up'; + ? `Level Up (${levelUpCost})` + : levelUpFee + ? `Level Up (from ${levelUpFee.amount} ${levelUpFee.symbol})` + : 'Level Up'; return ( <> @@ -106,7 +115,11 @@ const LevelUpPanel: React.FC = ({ isStandaloneView = true })
- + {buttonLabel}
@@ -116,6 +129,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/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..559659c0 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/index.css @@ -0,0 +1,366 @@ +/* ── 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 ───────────────────────────────────────────────────── */ + +/* Layout only — colour/glow come from . */ +.propose-button { + grid-column: 1 / -1; +} + +/* ── 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%); +} + +/* Confirm accept modal — body content only; chrome comes from . */ +.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; + margin-top: 16px; +} + +.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; + } +} diff --git a/frontend/src/components/pet/interactions/panels/marriage/index.tsx b/frontend/src/components/pet/interactions/panels/marriage/index.tsx index ab3ef5ca..2e4790cc 100644 --- a/frontend/src/components/pet/interactions/panels/marriage/index.tsx +++ b/frontend/src/components/pet/interactions/panels/marriage/index.tsx @@ -1,155 +1,24 @@ import React, { useMemo, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import { - 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 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`; - - return ( -
  • -
    - {pet.name} #{pet.id} - - #{info.proposalPetIdB?.toString()} -
    -
    - Expires {expiryLabel} - onCancel(pet.id)} - disabled={busy} - > - Cancel - -
    -
  • - ); -}; - -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; -}; +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'; +import './index.css'; const MarriagePanel: React.FC = ({ isStandaloneView = true }) => { const { kind, activeKind, walletAddress } = useChainCapabilities(); @@ -159,13 +28,11 @@ 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); const chainPets = useMemo( - () => kind === 'none' ? [] : pets.filter((p) => p.chain === (kind as 'evm' | 'solana')), + () => (kind === 'none' ? [] : pets.filter((p) => p.chain === (kind as 'evm' | 'solana'))), [pets, kind], ); const chainPetIds = useMemo(() => chainPets.map((p) => p.id), [chainPets]); @@ -182,10 +49,15 @@ const MarriagePanel: React.FC = ({ isStandaloneView = true } chainPetIds, ); - const busy = marriage.propose.isPending || marriage.accept.isPending - || marriage.cancel.isPending || marriage.divorce.isPending; + 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(); @@ -197,32 +69,43 @@ 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 ( <>
    @@ -233,169 +116,54 @@ 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..48b035a3 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-confirm-dialog.tsx @@ -0,0 +1,45 @@ +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 = { + 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, +}) => ( + +

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

    +
    + + + {isAccepting ? 'Accepting...' : '💒 Confirm'} + +
    +
    +); + +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..e6eb76ac --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/accept-tab.tsx @@ -0,0 +1,46 @@ +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..109b7946 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/active-marriages.tsx @@ -0,0 +1,38 @@ +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..68310d27 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/incoming-proposal-row.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { formatExpiry, type IncomingProposal } from '@shared/core'; +import NeonButton from '@components/ui/neon-button'; + +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)} + onAccept(proposal)}> + Accept + +
    +
  • +); + +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..ab5f5dc4 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-card.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { + useMarriageInfo, + useSpousePet, + type OpponentPet, + type Pet, + type PetChain, +} from '@shared/core'; +import { AuthActionButton } from '@components/common'; + +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, { skip: 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..137d32a3 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/marriage-tab-bar.tsx @@ -0,0 +1,31 @@ +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..ddc36734 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/outgoing-proposal-row.tsx @@ -0,0 +1,53 @@ +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..553a0013 --- /dev/null +++ b/frontend/src/components/pet/interactions/panels/marriage/parts/propose-tab.tsx @@ -0,0 +1,98 @@ +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/src/components/pet/interactions/panels/rename/index.tsx b/frontend/src/components/pet/interactions/panels/rename/index.tsx index 6bcb678f..f1486177 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,11 @@ 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..367b52d0 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 ( <> @@ -94,7 +93,11 @@ const TrainPanel: React.FC = ({ isStandaloneView = true }) => {
    - + {buttonLabel}
    diff --git a/frontend/src/components/pet/interactions/standalone/index.tsx b/frontend/src/components/pet/interactions/standalone/index.tsx index ca8eb054..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; @@ -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..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,102 +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); @@ -187,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); @@ -220,28 +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 8a7d6d42..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,10 +1,8 @@ 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 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'; @@ -21,12 +19,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 +35,13 @@ const SendPetModal: React.FC = ({ onClose(); }; - const { mutate, isPending, error: hookError, reset, lifecycle } = useTransferPet({ + const { + mutate, + isPending, + error: hookError, + reset, + lifecycle, + } = useTransferPet({ onSuccess: handleTransferComplete, }); @@ -87,69 +86,59 @@ const SendPetModal: React.FC = ({ } }; - 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/src/components/ui/icon/index.css b/frontend/src/components/ui/icon/index.css index af772e2e..1845a397 100644 --- a/frontend/src/components/ui/icon/index.css +++ b/frontend/src/components/ui/icon/index.css @@ -1,28 +1,42 @@ .neon-icon { - display: inline-flex; - align-items: center; - justify-content: center; - line-height: 0; - color: inherit; - vertical-align: -0.18em; - margin-inline-end: 0.4em; - flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + color: inherit; + vertical-align: -0.18em; + margin-inline-end: 0.4em; + flex-shrink: 0; - &.no-gap { - margin-inline-end: 0; - vertical-align: middle; - } + &.no-gap { + margin-inline-end: 0; + vertical-align: middle; + } - &.tone-cyan { color: var(--neon-cyan); } - &.tone-violet { color: var(--neon-violet); } - &.tone-magenta { color: var(--neon-magenta); } - &.tone-emerald { color: #9effd4; } - &.tone-amber { color: #ffd07b; } + &.tone-cyan { + color: var(--neon-cyan); + } + &.tone-violet { + color: var(--neon-violet); + } + &.tone-magenta { + color: var(--neon-magenta); + } + &.tone-emerald { + color: #9effd4; + } + &.tone-amber { + color: #ffd07b; + } - &.glow-soft { filter: drop-shadow(0 0 6px currentColor); } - &.glow-strong { filter: drop-shadow(0 0 10px currentColor) drop-shadow(0 0 18px currentColor); } + &.glow-soft { + filter: drop-shadow(0 0 6px currentColor); + } + &.glow-strong { + filter: drop-shadow(0 0 10px currentColor) drop-shadow(0 0 18px currentColor); + } - @media (prefers-reduced-motion: reduce) { - transition: none; - } + @media (prefers-reduced-motion: reduce) { + transition: none; + } } diff --git a/frontend/src/components/ui/icon/index.tsx b/frontend/src/components/ui/icon/index.tsx index 8aadb6fa..28bcbf47 100644 --- a/frontend/src/components/ui/icon/index.tsx +++ b/frontend/src/components/ui/icon/index.tsx @@ -1,20 +1,20 @@ import React from 'react'; import { - GiBiceps, - GiCrossedSwords, - GiCrystalShine, - GiDna1, - GiDragonHead, - GiEggPod, - GiFairyWand, - GiLinkedRings, - GiPaperPlane, - GiPawPrint, - GiQuillInk, - GiSandsOfTime, - GiSparkles, - GiSpellBook, - GiUpgrade, + GiBiceps, + GiCrossedSwords, + GiCrystalShine, + GiDna1, + GiDragonHead, + GiEggPod, + GiFairyWand, + GiLinkedRings, + GiPaperPlane, + GiPawPrint, + GiQuillInk, + GiSandsOfTime, + GiSparkles, + GiSpellBook, + GiUpgrade, } from 'react-icons/gi'; import { IoCheckmarkSharp, IoClose, IoCopy, IoPauseSharp, IoWarning } from 'react-icons/io5'; import { type Tone } from '@constants/tones'; @@ -25,66 +25,77 @@ export type IconGlow = 'none' | 'soft' | 'strong'; export type IconSize = number | string; export type IconProps = { - as: React.ComponentType<{ size?: number | string; className?: string; 'aria-hidden'?: boolean }>; - size?: IconSize; - tone?: IconTone; - glow?: IconGlow; - className?: string; - title?: string; + as: React.ComponentType<{ + size?: number | string; + className?: string; + 'aria-hidden'?: boolean; + }>; + size?: IconSize; + tone?: IconTone; + glow?: IconGlow; + className?: string; + title?: string; }; const TONE_CLASS: Record = { - cyan: 'tone-cyan', - violet: 'tone-violet', - magenta: 'tone-magenta', - emerald: 'tone-emerald', - amber: 'tone-amber', - inherit: 'tone-inherit', + cyan: 'tone-cyan', + violet: 'tone-violet', + magenta: 'tone-magenta', + emerald: 'tone-emerald', + amber: 'tone-amber', + inherit: 'tone-inherit', }; const GLOW_CLASS: Record = { - none: '', - soft: 'glow-soft', - strong: 'glow-strong', + none: '', + soft: 'glow-soft', + strong: 'glow-strong', }; const Icon: React.FC = ({ - as: Component, - size = '1em', - tone = 'inherit', - glow = 'soft', - className, - title, + as: Component, + size = '1em', + tone = 'inherit', + glow = 'soft', + className, + title, }) => { - const cls = ['neon-icon', TONE_CLASS[tone], GLOW_CLASS[glow], className].filter(Boolean).join(' '); - return ( - - - - ); + const cls = ['neon-icon', TONE_CLASS[tone], GLOW_CLASS[glow], className] + .filter(Boolean) + .join(' '); + return ( + + + + ); }; export default Icon; export { - GiBiceps as TrainIcon, - GiCrossedSwords as BattleIcon, - GiCrystalShine as CrystalIcon, - GiDna1 as DnaIcon, - GiDragonHead as DragonIcon, - GiEggPod as EggIcon, - GiFairyWand as MagicIcon, - GiLinkedRings as MarriageIcon, - GiPaperPlane as SendIcon, - GiPawPrint as PawIcon, - GiQuillInk as QuillIcon, - GiSandsOfTime as HourglassIcon, - GiSparkles as SparklesIcon, - GiSpellBook as SpellbookIcon, - GiUpgrade as LevelUpIcon, - IoCheckmarkSharp as CheckIcon, - IoClose as CloseIcon, - IoCopy as CopyIcon, - IoPauseSharp as PauseIcon, - IoWarning as WarningIcon, + GiBiceps as TrainIcon, + GiCrossedSwords as BattleIcon, + GiCrystalShine as CrystalIcon, + GiDna1 as DnaIcon, + GiDragonHead as DragonIcon, + GiEggPod as EggIcon, + GiFairyWand as MagicIcon, + GiLinkedRings as MarriageIcon, + GiPaperPlane as SendIcon, + GiPawPrint as PawIcon, + GiQuillInk as QuillIcon, + GiSandsOfTime as HourglassIcon, + GiSparkles as SparklesIcon, + GiSpellBook as SpellbookIcon, + GiUpgrade as LevelUpIcon, + IoCheckmarkSharp as CheckIcon, + IoClose as CloseIcon, + IoCopy as CopyIcon, + IoPauseSharp as PauseIcon, + IoWarning as WarningIcon, }; diff --git a/frontend/src/components/ui/neon-button/index.css b/frontend/src/components/ui/neon-button/index.css index dc724ae9..8b569a12 100644 --- a/frontend/src/components/ui/neon-button/index.css +++ b/frontend/src/components/ui/neon-button/index.css @@ -1,108 +1,125 @@ /* Base palette = azure (default tone; no .tone-azure rule needed) */ .neon-btn { - --btn-text: #8ed1ff; - --btn-border: rgb(30 157 255 / 50%); - --btn-glow: rgb(30 157 255 / 24%); - --btn-inner: rgb(30 157 255 / 14%); - --btn-highlight: rgb(255 255 255 / 3%); - - position: relative; - isolation: isolate; - border: 1px solid var(--btn-border); - border-radius: 2px; - background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); - color: var(--btn-text); - font-weight: 700; - letter-spacing: 0.95px; - text-transform: uppercase; - cursor: pointer; - box-shadow: - inset 0 0 12px var(--btn-inner), - inset 0 0 24px var(--btn-highlight), - 0 0 12px var(--btn-glow); - transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; - - &::before { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - background: linear-gradient(90deg, transparent, rgb(255 255 255 / 6%), transparent); - } - - & .label { + --btn-text: #8ed1ff; + --btn-border: rgb(30 157 255 / 50%); + --btn-glow: rgb(30 157 255 / 24%); + --btn-inner: rgb(30 157 255 / 14%); + --btn-highlight: rgb(255 255 255 / 3%); + position: relative; - z-index: 1; - } - - &:hover { - --btn-highlight: rgb(255 255 255 / 4%); - transform: translateY(-1px); - box-shadow: - inset 0 0 16px var(--btn-inner), - inset 0 0 30px var(--btn-highlight), - 0 0 16px var(--btn-glow), - 0 0 30px var(--btn-glow); - } - - &:active { - transform: translateY(0); - } - - &:focus:not(:focus-visible) { - outline: none; - } - - &:focus-visible { - outline: 2px solid var(--btn-border); - outline-offset: 2px; - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - } - - &.size-md { - padding: 12px 18px; - font-size: 0.92rem; - } - - &.size-sm { - padding: 8px 13px; - font-size: 0.82rem; - } - - &.full-width { - width: 100%; - } - - &.tone-emerald { - --btn-text: #9effd4; - --btn-border: rgb(0 255 157 / 48%); - --btn-glow: rgb(0 255 157 / 24%); - --btn-inner: rgb(0 255 157 / 14%); - } - - &.tone-amber { - --btn-text: #ffd07b; - --btn-border: rgb(255 181 67 / 44%); - --btn-glow: rgb(255 181 67 / 22%); - --btn-inner: rgb(255 181 67 / 12%); - } - - &.tone-cyan { - --btn-text: #86ebff; - --btn-border: rgb(0 247 255 / 36%); - --btn-glow: rgb(0 247 255 / 18%); - --btn-inner: rgb(0 247 255 / 10%); - } - - @media (prefers-reduced-motion: reduce) { - transition: none; + isolation: isolate; + border: 1px solid var(--btn-border); + border-radius: 2px; + background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%)); + color: var(--btn-text); + font-weight: 700; + letter-spacing: 0.95px; + text-transform: uppercase; + cursor: pointer; + box-shadow: inset 0 0 12px var(--btn-inner), inset 0 0 24px var(--btn-highlight), + 0 0 12px var(--btn-glow); + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; + + &::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 6%), transparent); + } + + & .label { + position: relative; + z-index: 1; + } &:hover { - transform: none; + --btn-highlight: rgb(255 255 255 / 4%); + transform: translateY(-1px); + box-shadow: inset 0 0 16px var(--btn-inner), inset 0 0 30px var(--btn-highlight), + 0 0 16px var(--btn-glow), 0 0 30px var(--btn-glow); + } + + &:active { + transform: translateY(0); + } + + &:focus:not(:focus-visible) { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--btn-border); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &.size-md { + padding: 12px 18px; + font-size: 0.92rem; + } + + &.size-sm { + padding: 8px 13px; + font-size: 0.82rem; + } + + /* Compact inline action (e.g. marriage row Divorce/Cancel). */ + &.size-xs { + padding: 4px 10px; + font-size: 0.75rem; + letter-spacing: 0.4px; + text-transform: none; + } + + &.full-width { + width: 100%; + } + + &.tone-emerald { + --btn-text: #9effd4; + --btn-border: rgb(0 255 157 / 48%); + --btn-glow: rgb(0 255 157 / 24%); + --btn-inner: rgb(0 255 157 / 14%); + } + + &.tone-amber { + --btn-text: #ffd07b; + --btn-border: rgb(255 181 67 / 44%); + --btn-glow: rgb(255 181 67 / 22%); + --btn-inner: rgb(255 181 67 / 12%); + } + + &.tone-cyan { + --btn-text: #86ebff; + --btn-border: rgb(0 247 255 / 36%); + --btn-glow: rgb(0 247 255 / 18%); + --btn-inner: rgb(0 247 255 / 10%); + } + + &.tone-violet { + --btn-text: #c5b3ff; + --btn-border: rgb(181 140 255 / 50%); + --btn-glow: rgb(181 140 255 / 22%); + --btn-inner: rgb(181 140 255 / 14%); + } + + &.tone-magenta { + --btn-text: #ff9ad6; + --btn-border: rgb(255 110 196 / 48%); + --btn-glow: rgb(255 110 196 / 22%); + --btn-inner: rgb(255 110 196 / 14%); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + + &:hover { + transform: none; + } } - } } diff --git a/frontend/src/components/ui/neon-button/index.tsx b/frontend/src/components/ui/neon-button/index.tsx index 06e4ce51..9324e2f3 100644 --- a/frontend/src/components/ui/neon-button/index.tsx +++ b/frontend/src/components/ui/neon-button/index.tsx @@ -4,37 +4,40 @@ import { type Tone } from '@constants/tones'; import './index.css'; -export type NeonButtonTone = Extract; -export type NeonButtonSize = 'md' | 'sm'; +export type NeonButtonTone = Extract< + Tone, + 'emerald' | 'azure' | 'amber' | 'cyan' | 'violet' | 'magenta' +>; +export type NeonButtonSize = 'md' | 'sm' | 'xs'; export type NeonButtonProps = ButtonHTMLAttributes & { - tone?: NeonButtonTone; - size?: NeonButtonSize; - fullWidth?: boolean; + tone?: NeonButtonTone; + size?: NeonButtonSize; + fullWidth?: boolean; }; const NeonButton = ({ - tone = 'azure', - size = 'md', - fullWidth = false, - className, - type = 'button', - children, - ...rest -}: NeonButtonProps) => { - const classString = clsx( - 'neon-btn', - `tone-${tone}`, - `size-${size}`, - fullWidth && 'full-width', + tone = 'azure', + size = 'md', + fullWidth = false, className, - ); + type = 'button', + children, + ...rest +}: NeonButtonProps) => { + const classString = clsx( + 'neon-btn', + `tone-${tone}`, + `size-${size}`, + fullWidth && 'full-width', + className, + ); - return ( - - ); + 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 a94b3854..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 [isCopied, setIsCopied] = 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(); @@ -70,8 +73,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(() => { @@ -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 && ( @@ -217,15 +237,17 @@ const AccountDropdown: React.FC = () => { {address && ( void handleCopyAny(address)} /> )} {solanaPublicKey && ( void handleCopyAny(solanaPublicKey.toString())} + isCopied={copiedAddress === solanaPublicKey.toString()} + onCopy={() => + void handleCopyAny(solanaPublicKey.toString()) + } /> )} {dynamicWalletAddress && @@ -233,7 +255,7 @@ const AccountDropdown: React.FC = () => { dynamicWalletAddress !== solanaPublicKey?.toString() && ( void handleCopyAny(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 dcf3bf87..646630dc 100644 --- a/frontend/src/constants/chains/solana.ts +++ b/frontend/src/constants/chains/solana.ts @@ -7,16 +7,19 @@ 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 }, ]; /** 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'; @@ -33,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/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 }; +}; 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 bb9bbfbf..3bf47301 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; @@ -22,4 +24,4 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; -} \ No newline at end of file +} 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/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(); diff --git a/frontend/tests/components/pet/panels/breed.test.tsx b/frontend/tests/components/pet/panels/breed.test.tsx index 5b618f2f..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; + }) => ( @@ -27,17 +35,24 @@ 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' }; 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: () => ({ @@ -46,7 +61,24 @@ vi.mock('@shared/core', () => ({ formatAmount: (v: bigint) => `${v}`, formatAmountOnly: (v: bigint) => String(v), }), - useMarriageInfo: () => ({ isMarried: false, spouseId: undefined }), + 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(), + }, usePendingBreed: () => ({ isPending: false }), usePetsConfig: () => ({ evm: undefined }), useBreedPets: (opts: { onSuccess?: (arg: { name: string }) => void }) => { @@ -54,8 +86,15 @@ vi.mock('@shared/core', () => ({ return breed; }, })); -// New sibling that reaches into PetsConfig/wagmi — stub it out. -vi.mock('@components/pet/interactions/panels/breed/pending-breed-notice', () => ({ + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ data: undefined, isLoading: false, error: null }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); +vi.mock('@components/pet/interactions/panels/breed/parts/pending-breed-notice', () => ({ + default: () => null, +})); +vi.mock('@components/pet/interactions/panels/breed/parts/stud-fee-balance', () => ({ default: () => null, })); @@ -72,9 +111,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(); @@ -83,7 +123,9 @@ describe('BreedPanel', () => { 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(); }); @@ -117,7 +159,9 @@ describe('BreedPanel', () => { 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', () => { @@ -132,8 +176,73 @@ describe('BreedPanel', () => { it('resets when breed.reset is called', () => { render(); - act(() => { breed.reset(); }); - // breed.reset is wired through capturedOnSuccess callback; just verify no crash + act(() => { + breed.reset(); + }); 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, + }); + }); +}); 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/frontend/tests/components/pet/panels/marriage.test.tsx b/frontend/tests/components/pet/panels/marriage.test.tsx index 0f8be4f4..d9e5df1a 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, @@ -53,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/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(); 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebd05233..32e0b437 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 @@ -330,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 @@ -3102,7 +3093,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 +4732,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 +18712,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 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/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 0f0883d6..264926bf 100644 --- a/shared/src/hooks/adapters/useSolanaAdapter.ts +++ b/shared/src/hooks/adapters/useSolanaAdapter.ts @@ -2,10 +2,13 @@ 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 { BattleResolvedResult } from '../../types/battle'; import type { ChainAdapter, AdapterMutation, TxLifecycle, TxPhase, ChainCapabilities } from './types'; export const SOLANA_CAPABILITIES: ChainCapabilities = { @@ -34,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'; @@ -41,17 +52,26 @@ 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, }; } +/** 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(); + const { program, programId } = useProgram(); const petsQuery = useSolanaPets(owner); const solanaPets = useMemo(() => { @@ -97,49 +117,92 @@ 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 battlePets: AdapterMutation<{ petId1: string; petId2: string; defenderOwner?: string }> = { + 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 }) { - 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), + lifecycle: battleLc, 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), + 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, }; + 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/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/chains/solana/usePendingSolanaBattle.ts b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts new file mode 100644 index 00000000..c83fef9e --- /dev/null +++ b/shared/src/hooks/chains/solana/usePendingSolanaBattle.ts @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useProgram } from './useProgram'; +import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; +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; +} + +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, 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, + enabled: enabled && isReady && Boolean(owner && program && programId), + queryFn: async () => { + if (!program || !programId || !owner) return null; + const [pda] = battleRequestPda(programId, owner); + 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, + 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 new file mode 100644 index 00000000..d7741a61 --- /dev/null +++ b/shared/src/hooks/chains/solana/usePendingSolanaBreed.ts @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useProgram } from './useProgram'; +import { useSolanaAnchor } from '../../../contexts/SolanaAnchorContext'; +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; +} + +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, 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, + enabled: enabled && isReady && Boolean(owner && program && programId), + queryFn: async () => { + if (!program || !programId || !owner) return null; + const [pda] = breedRequestPda(programId, owner); + 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, + canCancel, + cancel: { + run: cancelMutation.mutateAsync, + isPending: cancelMutation.isPending, + error: cancelMutation.error as Error | null, + }, + refetch, + }; +}; diff --git a/shared/src/hooks/chains/solana/usePetActions.ts b/shared/src/hooks/chains/solana/usePetActions.ts index cfa6988b..6825b5a9 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'; @@ -5,10 +6,13 @@ import { feeVaultPda, globalStatePda, petPdaByAsset, + studFeeAccountPda, } 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 { getAccountClient } from '../../../utils/solana/accountClient'; +import { MPL_CORE_PROGRAM_ID } from '../../../utils/solana/constants'; import { useProgram } from './useProgram'; export const usePetActions = () => { @@ -16,6 +20,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 = () => { @@ -107,32 +114,115 @@ 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(); + 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(); + 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(); + 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 * 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({ - 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 +243,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, }); @@ -176,8 +272,14 @@ export const usePetActions = () => { levelUpPet, trainPet, renamePet, + transferPet, + withdrawStudFees, + syncMetadata, + setOpenToChallenges, battlePets, + battleSubPhase, breedPets, + breedSubPhase, walletPublicKey: signingWallet?.publicKey ?? null, walletConnected: Boolean(signingWallet?.publicKey), }; diff --git a/shared/src/hooks/chains/solana/useProgram.ts b/shared/src/hooks/chains/solana/useProgram.ts index c692e342..98a4000b 100644 --- a/shared/src/hooks/chains/solana/useProgram.ts +++ b/shared/src/hooks/chains/solana/useProgram.ts @@ -28,38 +28,46 @@ export const useProgram = () => { [connection, providerWallet] ); - const query = useQuery({ - queryKey: [ - 'cryptopets', - 'program', - connection.rpcEndpoint, - programId?.toBase58() ?? 'none', - 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'); } + // 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.' ); } - 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/src/hooks/index.ts b/shared/src/hooks/index.ts index 083409de..b8a3f282 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -10,6 +10,15 @@ 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-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. +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'; @@ -41,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/useBattlePets.ts b/shared/src/hooks/useBattlePets.ts index 158c9e03..c9342b90 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 } @@ -69,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/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({ 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/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 }; +}; diff --git a/shared/src/hooks/useStudFees.ts b/shared/src/hooks/useStudFees.ts new file mode 100644 index 00000000..a3ab6c3c --- /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: async () => { await actions.withdrawStudFees.mutateAsync(); }, + isPending: actions.withdrawStudFees.isPending, + error: actions.withdrawStudFees.error as Error | null, + }, + refetch, + }; +}; 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, + }; +}; 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/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/pets/mapSolanaPet.ts b/shared/src/utils/pets/mapSolanaPet.ts index eaac6e2f..dc00a70e 100644 --- a/shared/src/utils/pets/mapSolanaPet.ts +++ b/shared/src/utils/pets/mapSolanaPet.ts @@ -66,7 +66,14 @@ 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, + openToChallenges: typeof a.openToChallenges === 'boolean' ? a.openToChallenges : undefined, }; } diff --git a/shared/src/utils/solana/accountClient.ts b/shared/src/utils/solana/accountClient.ts index f3ddcea1..dbbb25f9 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; @@ -24,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/src/utils/solana/battleWithSwitchboardVrf.ts b/shared/src/utils/solana/battleWithSwitchboardVrf.ts index 0ff0aae8..16a8affe 100644 --- a/shared/src/utils/solana/battleWithSwitchboardVrf.ts +++ b/shared/src/utils/solana/battleWithSwitchboardVrf.ts @@ -1,18 +1,43 @@ 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'; import { fetchAssetByPetId, getAccountClient } from './accountClient'; import { toU32 } from './numbers'; import { - COMMIT_REVEAL_WAIT_MS, - REVEAL_BACKOFF_MS, - REVEAL_RETRIES, + vrfTimingForEndpoint, sendSignedTx, waitForRevealIx, } 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) { @@ -38,11 +63,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 trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { + const { program, provider, programId, owner, onCommitted } = args; const connection = provider.connection; const [battleRequestKey] = battleRequestPda(programId, owner); const pending = await getAccountClient(program, 'battleRequest').fetchNullable(battleRequestKey); @@ -64,9 +91,11 @@ const trySettlePendingBattle = async (args: BattleWithVrfArgs): Promise => { +export const battleWithSwitchboardVrf = async (args: BattleWithVrfArgs): Promise => { const resumed = await trySettlePendingBattle(args); if (resumed) return resumed; @@ -109,6 +140,7 @@ export const battleWithSwitchboardVrf = async (args: BattleWithVrfArgs): Promise defenderPetId, attackerAssetKey, defenderOwner = owner, + onCommitted, } = args; const connection = provider.connection; @@ -157,9 +189,11 @@ export const battleWithSwitchboardVrf = async (args: BattleWithVrfArgs): Promise computeUnitLimitMultiple: 1.3, }); await sendSignedTx(provider, commitTx, [rngKp]); + onCommitted?.(); - await sleep(COMMIT_REVEAL_WAIT_MS); - const revealIx = await waitForRevealIx(randomness, owner, REVEAL_RETRIES, REVEAL_BACKOFF_MS); + const { commitRevealWaitMs, revealRetries, revealBackoffMs } = vrfTimingForEndpoint(connection.rpcEndpoint); + await sleep(commitRevealWaitMs); + const revealIx = await waitForRevealIx(randomness, owner, revealRetries, revealBackoffMs); const settleBattleIx = await program.methods .settleBattle() @@ -184,5 +218,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/src/utils/solana/breedWithSwitchboardVrf.ts b/shared/src/utils/solana/breedWithSwitchboardVrf.ts index 171394d5..923e378b 100644 --- a/shared/src/utils/solana/breedWithSwitchboardVrf.ts +++ b/shared/src/utils/solana/breedWithSwitchboardVrf.ts @@ -11,9 +11,7 @@ import { import { fetchAssetByPetId, getAccountClient } from './accountClient'; import { toU32 } from './numbers'; import { - COMMIT_REVEAL_WAIT_MS, - REVEAL_BACKOFF_MS, - REVEAL_RETRIES, + vrfTimingForEndpoint, sendSignedTx, waitForRevealIx, } from './switchboardVrfTx'; @@ -54,11 +52,13 @@ export type BreedWithVrfArgs = { * Required for cross-owner breeding. */ parent2Owner?: PublicKey; + /** Fires after commit tx confirms, while the oracle is fulfilling randomness. */ + onCommitted?: () => 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); @@ -88,9 +88,11 @@ const trySettlePendingBreed = async (args: BreedWithVrfArgs): 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/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/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 5aad4a77..669e7169 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,15 +28,26 @@ const actions = { levelUpPet: makeMutation(), trainPet: makeMutation(), renamePet: makeMutation(), - battlePets: makeMutation(), + battlePets: makeMutation({ sig: 'settle-sig', firstWins: true }), breedPets: makeMutation(), + transferPet: 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 }; 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' }, 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 }), +})); // Avoid loading Switchboard builders; expose only the real error formatter. vi.mock('../../src/utils/solana', async () => { const mod = await import('../../src/utils/solana/parseSolanaTransactionError'); @@ -53,9 +64,20 @@ 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 }); + 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 }); + 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 }; }); @@ -67,6 +89,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); @@ -85,7 +111,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', () => { @@ -117,9 +150,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 })); @@ -157,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 }); }); }); 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), }), })); 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'); + }); +}); diff --git a/shared/tests/utils/pets/mapSolanaPet.test.ts b/shared/tests/utils/pets/mapSolanaPet.test.ts index 3252a2a5..364e7568 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'); @@ -85,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(); + }); }); 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); + }); +});