Buy cards: store links, bulk buying from decks, and custom stores
The card preview gets Buy buttons that search TCGplayer, Card Kingdom and Star City Games by name. The deck bulk bar gets a Buy… action that sends the copies the selected cards are missing to TCGplayer or Card Kingdom as one pre-filled list.
The Info tab gets a Stores panel for adding your own shops, kept per device. An address is enough for TCGplayer Pro and Shopify storefronts, and any other shop can use a search link with {card} in it. Custom stores join the preview's Buy buttons and the bulk Buy… dialog, which opens a search per card because none of the shops tried can take a list from a link. On Android those open one tap at a time: once the browser is in front, Android blocks further links from the app as background activity starts.
On wide screens the Info tab's library stats move to the top of the left column.
Closes #32
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
api,
|
||||
@@ -66,7 +67,16 @@ import {
|
||||
shortBy,
|
||||
} from "../deckAnalysis";
|
||||
import { SuggestionsModal } from "../SuggestionsModal";
|
||||
import { useBackButton } from "../backButton";
|
||||
import {
|
||||
BULK_STORES,
|
||||
CUSTOM_STORES_KEY,
|
||||
CustomStore,
|
||||
customStoreUrl,
|
||||
STORE_URL_MAX,
|
||||
storeCardName,
|
||||
storeListLines,
|
||||
} from "../stores";
|
||||
import { isAndroid, useBackButton } from "../backButton";
|
||||
import { DeckList } from "./DeckList";
|
||||
|
||||
function libraryAddLabel(e: DeckEntry): string {
|
||||
@@ -381,6 +391,152 @@ function TagChips({
|
||||
);
|
||||
}
|
||||
|
||||
/** Above this many tabs, ask before opening a search per card. */
|
||||
const STORE_TABS_CONFIRM = 10;
|
||||
|
||||
/** Open each link in turn, with a beat between them so a browser that's still
|
||||
* starting up isn't handed them all at once. */
|
||||
async function openEach(urls: string[]) {
|
||||
for (const url of urls) {
|
||||
await openUrl(url);
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
|
||||
/** Send the copies the selected cards are missing to a store: as one list where
|
||||
* the store can take that, otherwise as a search per card in the user's own
|
||||
* stores. The store takes it from there, so this neither records the cards as
|
||||
* owned nor clears the selection.
|
||||
*
|
||||
* Android gets those searches one tap at a time. The first link puts the browser
|
||||
* in front, and from then on Android blocks links from the app as background
|
||||
* activity starts; sending them all at once beats the block, but Chrome then
|
||||
* drops some. Each tap comes from the app on screen, so none are lost. */
|
||||
function BuyCardsDialog({ entries, onClose }: { entries: DeckEntry[]; onClose: () => void }) {
|
||||
const [customStores] = usePersistedState<CustomStore[]>(CUSTOM_STORES_KEY, []);
|
||||
const [confirming, setConfirming] = useState<CustomStore | null>(null);
|
||||
const [step, setStep] = useState<{ store: CustomStore; opened: number } | null>(null);
|
||||
const missing = entries.filter((e) => shortBy(e) > 0);
|
||||
const copies = missing.reduce((n, e) => n + shortBy(e), 0);
|
||||
const cost = missing.reduce((sum, e) => sum + e.price_usd * shortBy(e), 0);
|
||||
const covered = entries.length - missing.length;
|
||||
const lines = storeListLines(missing.map((e) => ({ name: e.name, quantity: shortBy(e) })));
|
||||
const stores = BULK_STORES.map((s) => ({ name: s.name, url: s.url(lines) }));
|
||||
const tabs = `${missing.length} ${missing.length === 1 ? "tab" : "tabs"}`;
|
||||
|
||||
function searchEach(store: CustomStore) {
|
||||
onClose();
|
||||
openEach(missing.map((e) => customStoreUrl(store, e.name)));
|
||||
}
|
||||
|
||||
function openStep(store: CustomStore, index: number) {
|
||||
openUrl(customStoreUrl(store, missing[index].name));
|
||||
setStep({ store, opened: index + 1 });
|
||||
}
|
||||
|
||||
function search(store: CustomStore) {
|
||||
if (isAndroid) openStep(store, 0);
|
||||
else if (missing.length > STORE_TABS_CONFIRM) setConfirming(store);
|
||||
else searchEach(store);
|
||||
}
|
||||
|
||||
if (step) {
|
||||
const finished = step.opened === missing.length;
|
||||
return (
|
||||
<DialogFrame title={`Search ${step.store.name}`} onCancel={onClose}>
|
||||
<div className="buy-dialog">
|
||||
<div className="buy-summary">
|
||||
<div>
|
||||
Opened {step.opened} of {missing.length}
|
||||
</div>
|
||||
{!finished && <div className="hint">Next: {storeCardName(missing[step.opened].name)}</div>}
|
||||
</div>
|
||||
<div className="dialog-actions">
|
||||
{finished ? (
|
||||
<button className="primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={onClose}>Stop</button>
|
||||
<button className="primary" onClick={() => openStep(step.store, step.opened)}>
|
||||
Open next ({step.opened + 1} of {missing.length})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title={`Open ${tabs}?`}
|
||||
message={`This opens a separate search at ${confirming.name} for each card.`}
|
||||
confirmLabel={`Open ${tabs}`}
|
||||
onConfirm={() => searchEach(confirming)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFrame
|
||||
title={`Buy ${copies} ${copies === 1 ? "copy" : "copies"} of ${missing.length} ${
|
||||
missing.length === 1 ? "card" : "cards"
|
||||
}`}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="buy-dialog">
|
||||
{(cost > 0 || covered > 0) && (
|
||||
<div className="buy-summary">
|
||||
{cost > 0 && <div className="preview-info-price">Estimated {formatCost(cost)}</div>}
|
||||
{covered > 0 && (
|
||||
<div className="hint">
|
||||
Leaves out {covered} selected {covered === 1 ? "card" : "cards"} you already own enough of.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Send as one list</span>
|
||||
<div className="buy-stores">
|
||||
{stores.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
onClick={() => {
|
||||
openUrl(s.url);
|
||||
onClose();
|
||||
}}
|
||||
disabled={s.url.length > STORE_URL_MAX}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{stores.some((s) => s.url.length > STORE_URL_MAX) && (
|
||||
<div className="hint">Too many cards to send in one link. Select fewer.</div>
|
||||
)}
|
||||
</div>
|
||||
{customStores.length > 0 && (
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Search each card · {tabs}</span>
|
||||
<div className="buy-stores">
|
||||
{customStores.map((s) => (
|
||||
<button key={`${s.name}/${s.template}`} onClick={() => search(s)}>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** Add and remove tags across the selected cards. Each change saves at once. */
|
||||
function TagCardsDialog({
|
||||
entries,
|
||||
@@ -619,6 +775,7 @@ function DeckBuilder({
|
||||
// over a format's copy limit), keyed by oracle id → copies to remove.
|
||||
const [partialRemoval, setPartialRemoval] = useState<Map<string, number>>(new Map());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [buyDialogOpen, setBuyDialogOpen] = useState(false);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [handOpen, setHandOpen] = useState(false);
|
||||
@@ -1696,6 +1853,13 @@ function DeckBuilder({
|
||||
>
|
||||
Add to library
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBuyDialogOpen(true)}
|
||||
disabled={bulkBusy || !contents.some((e) => selectedCards.has(e.oracle_id) && shortBy(e) > 0)}
|
||||
title="Open the copies these cards are missing in a store"
|
||||
>
|
||||
Buy…
|
||||
</button>
|
||||
<button onClick={() => setTagDialogOpen(true)} disabled={bulkBusy}>
|
||||
Tag…
|
||||
</button>
|
||||
@@ -1870,6 +2034,12 @@ function DeckBuilder({
|
||||
onClose={() => setTagDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{buyDialogOpen && selectedCards.size > 0 && (
|
||||
<BuyCardsDialog
|
||||
entries={contents.filter((e) => selectedCards.has(e.oracle_id))}
|
||||
onClose={() => setBuyDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{handOpen && (
|
||||
<SampleHandModal
|
||||
hand={hand}
|
||||
|
||||
Reference in New Issue
Block a user