From c7572ae7b0a47220bba282bfc33553f7c5b4f700 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Sun, 1 Nov 2026 10:25:14 -0600 Subject: [PATCH] Buy cards: store links, bulk buying from decks, and custom stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 4 + src/StoresPanel.tsx | 113 +++++++++++++++++++++++++++ src/components.tsx | 28 ++++++- src/stores.ts | 96 +++++++++++++++++++++++ src/styles.css | 31 +++++++- src/views/DeckView.tsx | 172 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 437 insertions(+), 7 deletions(-) create mode 100644 src/StoresPanel.tsx create mode 100644 src/stores.ts diff --git a/src/App.tsx b/src/App.tsx index f030b05..e6f28c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { } from "./components"; import { CommandPalette } from "./CommandPalette"; import { LibrariesPanel, LibrarySwitcher } from "./Libraries"; +import { StoresPanel } from "./StoresPanel"; import { SyncIndicator, SyncPanel } from "./SyncPanel"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; @@ -850,6 +851,9 @@ export default function App() { onChanged={librariesChanged} /> +
+ +
diff --git a/src/StoresPanel.tsx b/src/StoresPanel.tsx new file mode 100644 index 0000000..b6bc533 --- /dev/null +++ b/src/StoresPanel.tsx @@ -0,0 +1,113 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useState } from "react"; +import { DialogFrame, IconButton, Icons, usePersistedState } from "./components"; +import { CUSTOM_STORES_KEY, CustomStore, customStoreUrl, storeTemplate } from "./stores"; + +/** Add a store: a name, and either its address or a search link of its own. */ +function AddStoreDialog({ onAdd, onCancel }: { onAdd: (store: CustomStore) => void; onCancel: () => void }) { + const [name, setName] = useState(""); + const [address, setAddress] = useState(""); + const [error, setError] = useState(null); + + function add() { + if (!name.trim()) return; + try { + onAdd({ name: name.trim(), template: storeTemplate(address) }); + } catch (err) { + setError((err as Error).message); + } + } + + const onKeyDown = (e: React.KeyboardEvent) => e.key === "Enter" && add(); + + return ( + + + +

+ The address works for TCGplayer Pro and Shopify stores. For any other store, search it for a card, paste + that link here, and put {"{card}"} where the card's name was. +

+ {error &&

{error}

} +
+ + +
+
+ ); +} + +/** The user's own stores, which join the Buy buttons in the card preview. */ +export function StoresPanel() { + const [stores, setStores] = usePersistedState(CUSTOM_STORES_KEY, []); + const [adding, setAdding] = useState(false); + + return ( + <> +

Stores

+
+ Add your local game stores to search them from any card. These are kept on this device only. +
+ {stores.length > 0 && ( +
    + {stores.map((s, i) => ( +
  • +
    + {s.name} + {new URL(customStoreUrl(s, "card")).hostname} +
    +
    + + setStores(stores.filter((_, n) => n !== i))} + /> +
    +
  • + ))} +
+ )} +
+ +
+ {adding && ( + { + setStores([...stores, store]); + setAdding(false); + }} + onCancel={() => setAdding(false)} + /> + )} + + ); +} diff --git a/src/components.tsx b/src/components.tsx index b9bd292..fe8192f 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -10,6 +10,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { api, CardRef, @@ -24,6 +25,7 @@ import { PrintingRef, } from "./api"; import { useBackButton } from "./backButton"; +import { CUSTOM_STORES_KEY, CustomStore, customStoreUrl, STORES, storeQuery } from "./stores"; // --- Icons ------------------------------------------------------------ @@ -1019,10 +1021,31 @@ function LegalityPills({ legalities }: { legalities: number }) { ); } +/** Buttons that search each of `STORES`, and the user's own, for a card. */ +function StoreLinks({ name }: { name: string }) { + const [custom] = usePersistedState(CUSTOM_STORES_KEY, []); + const stores = [ + ...STORES.map((s) => ({ name: s.name, url: s.url(storeQuery(name)) })), + ...custom.map((s) => ({ name: s.name, url: customStoreUrl(s, name) })), + ]; + return ( +
+ Buy + + {stores.map((s) => ( + + ))} + +
+ ); +} + /** Details panel shown alongside the card art in the click-to-preview modal: * type/mana/color, price, ownership context (when the caller had a - * `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format - * legality. */ + * `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), per-format legality, + * and links to stores. */ const CardInfoTile = forwardRef< HTMLDivElement, { card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void; onClose: () => void } @@ -1081,6 +1104,7 @@ const CardInfoTile = forwardRef< Legalities
+ ); }); diff --git a/src/stores.ts b/src/stores.ts new file mode 100644 index 0000000..c7699b4 --- /dev/null +++ b/src/stores.ts @@ -0,0 +1,96 @@ +// Where a card can be bought: the built-in stores, and links to the user's own. + +/** Stores a card can be looked up in. Searches by name, so a link covers every + * printing and needs nothing from the card database. */ +export const STORES = [ + { + name: "TCGplayer", + url: (q: string) => `https://www.tcgplayer.com/search/magic/product?productLineName=magic&q=${q}`, + }, + { + name: "Card Kingdom", + url: (q: string) => `https://www.cardkingdom.com/catalog/search?filter%5Bname%5D=${q}`, + }, + { + name: "Star City Games", + url: (q: string) => `https://starcitygames.com/search/?search_query=${q}`, + }, +]; + +/** Stores that can pre-fill a whole shopping list from a link. Each takes lines + * like "2 Lightning Bolt"; see `storeListLines`. */ +export const BULK_STORES = [ + { + name: "TCGplayer", + url: (lines: string[]) => + `https://www.tcgplayer.com/massentry?productline=Magic&c=${lines.map(encodeURIComponent).join("||")}`, + }, + { + name: "Card Kingdom", + url: (lines: string[]) => `https://www.cardkingdom.com/builder?c=${encodeURIComponent(lines.join("\n"))}`, + }, +]; + +/** Longest store link to offer. TCGplayer refused 11,000 characters; both + * stores took 2,800. */ +export const STORE_URL_MAX = 8000; + +/** A store the user added. Search only: none of the shops tried could take a + * whole list from a link, so a bulk buy opens a search per card instead of + * joining `BULK_STORES`. */ +export interface CustomStore { + name: string; + /** A search link, with `{card}` where the card's name goes. */ + template: string; +} + +/** Not `libraryKey`: these are the person's shops, whichever library is open. */ +export const CUSTOM_STORES_KEY = "custom-stores"; + +/** The name to look a card up by. Double-faced and split cards are named + * "A // B"; stores find those more reliably by the front name alone. */ +export const storeCardName = (name: string) => name.split(" // ")[0]; + +/** The URL-safe search term for a card, for `STORES`. */ +export const storeQuery = (name: string) => encodeURIComponent(storeCardName(name)); + +/** A shopping list in the form `BULK_STORES` take. */ +export function storeListLines(cards: { name: string; quantity: number }[]): string[] { + return cards.map((c) => `${c.quantity} ${storeCardName(c.name)}`); +} + +/** + * Turn what the user typed into a `CustomStore` template. Their own link is kept + * if it says where the card goes; otherwise a shop's address is enough, since + * TCGplayer Pro and Shopify storefronts each search the same way everywhere. + * + * Throws if it isn't a web address: the result is handed to `openUrl`. + */ +export function storeTemplate(address: string): string { + const typed = address.trim(); + // The lookahead lets "shop.example.com:8080" through as an address with a port. + const link = /^[a-z][a-z0-9+.-]*:(?!\d)/i.test(typed) ? typed : `https://${typed}`; + let url: URL; + try { + url = new URL(link.split("{card}").join("card")); + } catch { + throw new Error("Enter a web address, like https://shop.example.com"); + } + // Checks the hostname itself because `URL` lets some nonsense through: it + // reads "not a url" as a host, escaping the spaces. + const isWeb = url.protocol === "https:" || url.protocol === "http:"; + if (!isWeb || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(url.hostname)) { + throw new Error("Enter a web address, like https://shop.example.com"); + } + if (link.includes("{card}")) return link; + if (url.hostname.endsWith(".tcgplayerpro.com")) { + return `${url.origin}/search/products?productLineName=Magic%3A%20The%20Gathering&q={card}`; + } + return `${url.origin}/search?q={card}`; +} + +/** The link that searches a custom store for a card. */ +export function customStoreUrl(store: CustomStore, cardName: string): string { + // Not `replaceAll`: a "$" in the replacement would be read as a pattern. + return store.template.split("{card}").join(storeQuery(cardName)); +} diff --git a/src/styles.css b/src/styles.css index 9fe0c9f..1858fce 100644 --- a/src/styles.css +++ b/src/styles.css @@ -814,7 +814,8 @@ input[type="checkbox"] { /* Info page: a column of panels on narrow screens, two columns on wide ones. Below the breakpoint the two `.info-col` wrappers dissolve (display: contents) so `order` can interleave their panels into the reading order - the single column always had: card data, libraries, sync, summary, about. */ + the single column always had: card data, libraries, stores, sync, summary, + about. */ .info-grid { display: flex; flex-direction: column; @@ -831,9 +832,10 @@ input[type="checkbox"] { } .info-db { order: 1; } .info-libraries { order: 2; } -.info-sync { order: 3; } -.info-summary { order: 4; } -.info-about { order: 5; } +.info-stores { order: 3; } +.info-sync { order: 4; } +.info-summary { order: 5; } +.info-about { order: 6; } @media (min-width: 900px) { .info-grid { display: grid; @@ -847,6 +849,7 @@ input[type="checkbox"] { gap: var(--space-4); min-width: 0; } + .info-summary { order: 0; } } .info-about-row { display: flex; @@ -1349,6 +1352,26 @@ button.legality-tile-select { justify-content: flex-end; gap: 8px; } +.buy-dialog { + display: flex; + flex-direction: column; + gap: 12px; +} +.buy-summary { + display: flex; + flex-direction: column; + gap: 2px; +} +/* A shared grid, so a lone store lines up with the column above it rather than + stretching across the dialog. */ +.buy-stores { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 8px; +} +.buy-stores button { + justify-content: center; +} /* ============================================================ Lists (search results, deck list) diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 9c9d73e..10e2f6c 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -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(CUSTOM_STORES_KEY, []); + const [confirming, setConfirming] = useState(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 ( + +
+
+
+ Opened {step.opened} of {missing.length} +
+ {!finished &&
Next: {storeCardName(missing[step.opened].name)}
} +
+
+ {finished ? ( + + ) : ( + <> + + + + )} +
+
+
+ ); + } + + if (confirming) { + return ( + searchEach(confirming)} + onCancel={() => setConfirming(null)} + /> + ); + } + + return ( + +
+ {(cost > 0 || covered > 0) && ( +
+ {cost > 0 &&
Estimated {formatCost(cost)}
} + {covered > 0 && ( +
+ Leaves out {covered} selected {covered === 1 ? "card" : "cards"} you already own enough of. +
+ )} +
+ )} +
+ Send as one list +
+ {stores.map((s) => ( + + ))} +
+ {stores.some((s) => s.url.length > STORE_URL_MAX) && ( +
Too many cards to send in one link. Select fewer.
+ )} +
+ {customStores.length > 0 && ( +
+ Search each card · {tabs} +
+ {customStores.map((s) => ( + + ))} +
+
+ )} +
+
+ ); +} + /** 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>(new Map()); const [bulkBusy, setBulkBusy] = useState(false); + const [buyDialogOpen, setBuyDialogOpen] = useState(false); const selectAllRef = useRef(null); const [handOpen, setHandOpen] = useState(false); @@ -1696,6 +1853,13 @@ function DeckBuilder({ > Add to library + @@ -1870,6 +2034,12 @@ function DeckBuilder({ onClose={() => setTagDialogOpen(false)} /> )} + {buyDialogOpen && selectedCards.size > 0 && ( + selectedCards.has(e.oracle_id))} + onClose={() => setBuyDialogOpen(false)} + /> + )} {handOpen && (