Add touch-friendly shop interactions on phones.

A phone has no hover to preview a card, so tapping now opens a magnified,
fully-readable view with the actions that fit it instead of acting blind:

- Shop card: tap to see it big, then Buy (or read it even when unaffordable).
- Your deck card: tap to see it big, then Sell it, or Select to enter a
  bulk-selection mode where taps toggle pets (no magnify) and the action bar
  offers Sell / Triple / Cancel.

Desktop keeps click-to-buy and click-to-select unchanged; the new behavior is
gated on a max-width:600px media query via a small useMediaQuery hook.
This commit is contained in:
Greyson Parrelli
2026-07-26 15:22:41 -04:00
parent 33bef8f58a
commit 9a3ee34357
3 changed files with 222 additions and 38 deletions
+173 -38
View File
@@ -4,6 +4,7 @@ import { createPortal } from 'react-dom'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
import { useCardAnimations } from '../anim'
import { useMediaQuery } from '../useMediaQuery'
import { artFor, artUrlFor } from '../petArt'
interface Flyer {
@@ -69,6 +70,15 @@ interface Props {
export function ShopPhase({ view, you, send }: Props) {
const [selected, setSelected] = useState<string[]>([])
const [confirmPass, setConfirmPass] = useState(false)
// Phones have no hover to preview a card, so a tap opens a magnified view with
// action buttons instead of acting immediately. `focus` is the tapped card
// shown big; `selectMode` is the deck's bulk-selection mode (tap to select
// without magnifying, then sell/triple). Both are ignored on desktop.
const isPhone = useMediaQuery('(max-width: 600px)')
const [focus, setFocus] = useState<{ kind: 'shop' | 'deck'; card: Card; row?: number } | null>(
null,
)
const [selectMode, setSelectMode] = useState(false)
// When set, the next buy is paid by discarding an Avocado instead of a coin
// (Golden pack). It also kicks in automatically when out of coins.
const [useAvocado, setUseAvocado] = useState(false)
@@ -227,6 +237,26 @@ export function ShopPhase({ view, you, send }: Props) {
setUseAvocado(false)
}
// Bulk actions on the current selection; also leave mobile select mode.
const sellSelected = () => {
act({ type: 'sell', cards: selected })
setSelectMode(false)
}
const tradeSelected = () => {
act({ type: 'trade', cards: selected })
setSelectMode(false)
}
// Sell just the one card shown in the magnified view.
const sellOne = (id: string) => {
act({ type: 'sell', cards: [id] })
setFocus(null)
}
// Buy the card shown in the magnified shop view, then close it.
const buyFocused = (row: number) => {
buy(row)
setFocus(null)
}
return (
<div className="shop">
{/* Status line */}
@@ -286,8 +316,19 @@ export function ShopPhase({ view, you, send }: Props) {
<CardView
card={c}
size="lg"
disabled={!canBuy}
onClick={canBuy ? () => buy(i) : undefined}
// Phone: a tap opens the magnified view (with a Buy button)
// rather than buying blind; and it stays tappable to read even
// when unaffordable. Desktop keeps click-to-buy.
disabled={isPhone ? false : !canBuy}
onClick={
isPhone
? selectMode
? undefined
: () => setFocus({ kind: 'shop', card: c, row: i })
: canBuy
? () => buy(i)
: undefined
}
/>
</div>
) : (
@@ -371,7 +412,14 @@ export function ShopPhase({ view, you, send }: Props) {
<CardView
card={c}
selected={selected.includes(c.id)}
onClick={() => toggle(c.id)}
// Phone: tap opens the magnified view (Sell / Select); once
// in select mode, tap toggles selection directly. Desktop
// toggles selection on tap.
onClick={
isPhone && !selectMode
? () => setFocus({ kind: 'deck', card: c })
: () => toggle(c.id)
}
/>
</div>
)
@@ -412,44 +460,131 @@ export function ShopPhase({ view, you, send }: Props) {
)}
</section>
{/* Actions */}
{/* Actions. On a phone, selling and tripling happen inside select mode
(entered from a pet's magnified view); the bar then shows those bulk
actions plus Cancel. Otherwise it's just Pass. Desktop is unchanged. */}
{isPhone && selectMode && (
<div className="hint">Tap pets to select, then sell or triple or cancel.</div>
)}
<div className="actions">
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={() => act({ type: 'sell', cards: selected })}
title="Convert selected cards into apples (+1 power each, this battle only) — free"
>
Sell {selected.length > 0 ? selected.length : ''} 🍎
</button>
<button
className="btn btn-secondary"
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
onClick={() => act({ type: 'trade', cards: selected })}
title="Triple 3 same-suit pets for a pick from the next tier — free"
>
Triple 3{' '}
{sameSuit && selectedCards[0].suit ? (
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
) : (
'matching'
)}{' '}
Tier {Math.min(view.round + 1, view.maxRounds)}
</button>
<button
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
disabled={!myTurn || overPets}
onClick={() => setConfirmPass(true)}
title={
overPets
? `Sell down to ${view.maxPets} pets before passing`
: 'End your shopping for this round'
}
>
Pass
</button>
{(!isPhone || selectMode) && (
<button
className="btn btn-secondary"
disabled={!myTurn || selected.length === 0}
onClick={sellSelected}
title="Convert selected cards into apples (+1 power each, this battle only) — free"
>
Sell {selected.length > 0 ? selected.length : ''} 🍎
</button>
)}
{(!isPhone || selectMode) && (
<button
className="btn btn-secondary"
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
onClick={tradeSelected}
title="Triple 3 same-suit pets for a pick from the next tier — free"
>
Triple 3{' '}
{sameSuit && selectedCards[0].suit ? (
<span className={`suit-dot suit-${selectedCards[0].suit}`} />
) : (
'matching'
)}{' '}
Tier {Math.min(view.round + 1, view.maxRounds)}
</button>
)}
{isPhone && selectMode && (
<button
className="btn btn-ghost"
onClick={() => {
setSelected([])
setSelectMode(false)
}}
>
Cancel
</button>
)}
{(!isPhone || !selectMode) && (
<button
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
disabled={!myTurn || overPets}
onClick={() => setConfirmPass(true)}
title={
overPets
? `Sell down to ${view.maxPets} pets before passing`
: 'End your shopping for this round'
}
>
Pass
</button>
)}
</div>
{/* Phone: a tapped card shown big, with the actions that fit it — Buy for
a shop card, Sell / Select for one of yours. Reading the effect at a
readable size is the whole point, so this always shows the full card. */}
{focus &&
createPortal(
<div className="modal-backdrop" onClick={() => setFocus(null)}>
<div className="card-focus" onClick={(e) => e.stopPropagation()}>
<CardView card={focus.card} size="lg" preview />
{focus.kind === 'shop' && !canBuy && (
<div className="hint">
{!myTurn ? 'Wait for your turn to buy.' : 'Not enough gold for this.'}
</div>
)}
<div className="card-focus-actions">
{focus.kind === 'shop' ? (
<>
<button
className="btn btn-primary btn-big"
disabled={!canBuy}
onClick={() => buyFocused(focus.row!)}
>
{freeBuy
? 'Buy free 🎉'
: you.coins > 0
? 'Buy · 1 🪙'
: avocados > 0
? 'Buy · 🥑'
: 'Buy'}
</button>
<button className="btn btn-ghost" onClick={() => setFocus(null)}>
Close
</button>
</>
) : (
<>
<button
className="btn btn-secondary"
disabled={!myTurn}
onClick={() => sellOne(focus.card.id)}
title="Sell for an apple (+1 power this battle) — free"
>
Sell 🍎
</button>
<button
className="btn btn-primary"
onClick={() => {
setSelectMode(true)
setSelected([focus.card.id])
setFocus(null)
}}
title="Select multiple pets to sell or triple together"
>
Select
</button>
<button className="btn btn-ghost" onClick={() => setFocus(null)}>
Close
</button>
</>
)}
</div>
</div>
</div>,
document.body,
)}
{/* Pass confirmation */}
{confirmPass && (
<div className="modal-backdrop" onClick={() => setConfirmPass(false)}>
+26
View File
@@ -3047,6 +3047,32 @@ h3 {
font-size: 0.86rem;
}
/* Tap-to-magnify overlay: the card shown big and centered with its actions.
Overrides the shrunk card sizes/fonts — reading the card is the point. */
.card-focus {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
max-width: 92vw;
}
.card-focus .card {
width: min(66vw, 230px);
height: min(58vh, 312px);
}
.card-focus .card-name {
font-size: 0.95rem;
}
.card-focus .card-effect {
font-size: 0.66rem;
}
.card-focus-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
/* --- avocado / trumpet / mana trays --- */
.avocado-zone {
padding: 8px 10px 10px;
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react'
// useMediaQuery tracks whether a CSS media query currently matches, re-rendering
// when it flips (viewport resize, orientation change). Used to switch on the
// phone layout's touch interactions, which mirror the `max-width: 600px` styles.
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' && !!window.matchMedia
? window.matchMedia(query).matches
: false,
)
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
const mql = window.matchMedia(query)
const onChange = () => setMatches(mql.matches)
onChange()
mql.addEventListener('change', onChange)
return () => mql.removeEventListener('change', onChange)
}, [query])
return matches
}