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:
@@ -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)}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user