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 type { Card, ClientMessage, GameView, PlayerView } from '../types'
|
||||||
import { CardView } from './CardView'
|
import { CardView } from './CardView'
|
||||||
import { useCardAnimations } from '../anim'
|
import { useCardAnimations } from '../anim'
|
||||||
|
import { useMediaQuery } from '../useMediaQuery'
|
||||||
import { artFor, artUrlFor } from '../petArt'
|
import { artFor, artUrlFor } from '../petArt'
|
||||||
|
|
||||||
interface Flyer {
|
interface Flyer {
|
||||||
@@ -69,6 +70,15 @@ interface Props {
|
|||||||
export function ShopPhase({ view, you, send }: Props) {
|
export function ShopPhase({ view, you, send }: Props) {
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const [confirmPass, setConfirmPass] = useState(false)
|
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
|
// 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.
|
// (Golden pack). It also kicks in automatically when out of coins.
|
||||||
const [useAvocado, setUseAvocado] = useState(false)
|
const [useAvocado, setUseAvocado] = useState(false)
|
||||||
@@ -227,6 +237,26 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
setUseAvocado(false)
|
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 (
|
return (
|
||||||
<div className="shop">
|
<div className="shop">
|
||||||
{/* Status line */}
|
{/* Status line */}
|
||||||
@@ -286,8 +316,19 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
<CardView
|
<CardView
|
||||||
card={c}
|
card={c}
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!canBuy}
|
// Phone: a tap opens the magnified view (with a Buy button)
|
||||||
onClick={canBuy ? () => buy(i) : undefined}
|
// 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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -371,7 +412,14 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
<CardView
|
<CardView
|
||||||
card={c}
|
card={c}
|
||||||
selected={selected.includes(c.id)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -412,20 +460,28 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<div className="actions">
|
||||||
|
{(!isPhone || selectMode) && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={!myTurn || selected.length === 0}
|
disabled={!myTurn || selected.length === 0}
|
||||||
onClick={() => act({ type: 'sell', cards: selected })}
|
onClick={sellSelected}
|
||||||
title="Convert selected cards into apples (+1 power each, this battle only) — free"
|
title="Convert selected cards into apples (+1 power each, this battle only) — free"
|
||||||
>
|
>
|
||||||
Sell {selected.length > 0 ? selected.length : ''} → 🍎
|
Sell {selected.length > 0 ? selected.length : ''} → 🍎
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{(!isPhone || selectMode) && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
|
disabled={!myTurn || !sameSuit || view.round >= view.maxRounds}
|
||||||
onClick={() => act({ type: 'trade', cards: selected })}
|
onClick={tradeSelected}
|
||||||
title="Triple 3 same-suit pets for a pick from the next tier — free"
|
title="Triple 3 same-suit pets for a pick from the next tier — free"
|
||||||
>
|
>
|
||||||
Triple 3{' '}
|
Triple 3{' '}
|
||||||
@@ -436,6 +492,19 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
)}{' '}
|
)}{' '}
|
||||||
↑ Tier {Math.min(view.round + 1, view.maxRounds)}
|
↑ Tier {Math.min(view.round + 1, view.maxRounds)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{isPhone && selectMode && (
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSelected([])
|
||||||
|
setSelectMode(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(!isPhone || !selectMode) && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
|
className={`btn btn-ghost ${passHint ? 'btn-pass-hint' : ''}`}
|
||||||
disabled={!myTurn || overPets}
|
disabled={!myTurn || overPets}
|
||||||
@@ -448,8 +517,74 @@ export function ShopPhase({ view, you, send }: Props) {
|
|||||||
>
|
>
|
||||||
Pass
|
Pass
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Pass confirmation */}
|
||||||
{confirmPass && (
|
{confirmPass && (
|
||||||
<div className="modal-backdrop" onClick={() => setConfirmPass(false)}>
|
<div className="modal-backdrop" onClick={() => setConfirmPass(false)}>
|
||||||
|
|||||||
@@ -3047,6 +3047,32 @@ h3 {
|
|||||||
font-size: 0.86rem;
|
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 / trumpet / mana trays --- */
|
||||||
.avocado-zone {
|
.avocado-zone {
|
||||||
padding: 8px 10px 10px;
|
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