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 && (