diff --git a/web/src/components/ShopPhase.tsx b/web/src/components/ShopPhase.tsx index 6c7aa43..96dd7ae 100644 --- a/web/src/components/ShopPhase.tsx +++ b/web/src/components/ShopPhase.tsx @@ -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([]) 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 (
{/* Status line */} @@ -286,8 +316,19 @@ export function ShopPhase({ view, you, send }: Props) { 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 + } />
) : ( @@ -371,7 +412,14 @@ export function ShopPhase({ view, you, send }: Props) { 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) + } /> ) @@ -412,44 +460,131 @@ export function ShopPhase({ view, you, send }: Props) { )} - {/* 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 && ( +
Tap pets to select, then sell or triple โ€” or cancel.
+ )}
- - - + {(!isPhone || selectMode) && ( + + )} + {(!isPhone || selectMode) && ( + + )} + {isPhone && selectMode && ( + + )} + {(!isPhone || !selectMode) && ( + + )}
+ {/* 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( +
setFocus(null)}> +
e.stopPropagation()}> + + {focus.kind === 'shop' && !canBuy && ( +
+ {!myTurn ? 'Wait for your turn to buy.' : 'Not enough gold for this.'} +
+ )} +
+ {focus.kind === 'shop' ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+
, + document.body, + )} + {/* Pass confirmation */} {confirmPass && (
setConfirmPass(false)}> diff --git a/web/src/styles.css b/web/src/styles.css index d1a6f5e..b33d9a8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; diff --git a/web/src/useMediaQuery.ts b/web/src/useMediaQuery.ts new file mode 100644 index 0000000..3dda5ab --- /dev/null +++ b/web/src/useMediaQuery.ts @@ -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 +}