2025-07-23 22:26:54 -04:00
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
|
import Layout from '../../components/Layout';
|
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)
The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.
Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).
Architect to address:
- Q1: logged-out rendering branch design (navbar, mobile-nav,
auth-only items treatment)
- Q2: page audit triage into always-auth / public-or-auth /
anonymous-allowed buckets
- Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
- Q4: whether to add vitest coverage for the logged-out branch
(recommend yes — small surface, high regression protection)
Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).
depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)
2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).
Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
UserProfileDropdown when logged out. Adds first jsdom test in the repo
at test/components/Layout.test.js (Decision D2) with 5 regression-lock
assertions. devDeps: jsdom@^29, @testing-library/react@^16.
Brief 2: page audit sweep — 7 pages need code changes:
- Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
deck/[id].js (×3), decks.js (×3)
- Replace page-level useState({email: 'me@...'}) → useState(null) +
null-guards: profile.js, settings.js
- Replace hardcoded const user = {email: 'me@...'} with useAuth():
card/[id].js
Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.
Decisions:
A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
hides auth-only dropdown (Profile/Settings/Logout/Admin);
keeps public + community nav visible
B — Per-page bucket assignment (10 already correct, 7 need fix);
full per-page table with justification in convoy file
C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
sweep depends on Brief 1). C1 buries the conceptual change under
mechanical edits; C3 is over-orchestrated for this scope
D2 — vitest lock-in; first jsdom test in repo; same negative-regression
style as test/lib/permission-middleware.test.js (synthetic-admin
shape). devDeps jsdom + @testing-library/react
Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).
MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.
Flagged-but-deferred:
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- Layout headers still render "Deck Hearth" / "DH" branding
→ pick-a-name (queued P1 #12)
- MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
or fold into god-component-split
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)
Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.
components/Layout.js:
- Default user prop is now null (was hardcoded to
{ email: 'me@randallstillwell.com', role: 'user' })
- UserProfileDropdown renders a "Sign in" link to /login when
user === null instead of the maintainer's email + auth-only menu
items (Decision A1)
- All user.* accesses guarded with optional chaining or null checks
- useState hook stays above the new null-user early return to satisfy
rules-of-hooks (boot-the-brief caught this on the first try;
see AGENTS.md Gotcha #11.5)
test/components/Layout.test.js (new):
- First jsdom test in the repo (Decision D2)
- 5 regression-lock assertions: no maintainer email ever rendered
(prop omitted, prop=null), Sign-in link exists with href=/login,
supplied email renders when prop is set, no "Guest" placeholder
(locks A1 copy choice)
- Mocks next/link, next/router (prefetch, replace, events, query),
and theme-context.useTheme for jsdom safety under Next 16
package.json + package-lock.json:
- Add jsdom@^29 and @testing-library/react@^16 to devDependencies
- @testing-library/dom@^10 added explicitly (peer auto-install
skipped it under npm 11; brief anticipated this fallback)
vitest.config.js (deviation from brief — see PR description):
- Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
parse JSX in .js files. Required to import any React component
written in the repo's Next.js pages-router .js convention
(AGENTS.md Gotcha #9). The brief said "no change" to this file,
but JSX-in-.js parsing is a hard prerequisite for the new test
to import components/Layout.js — the alternatives (rename test
to .test.jsx; rewrite test in React.createElement) either break
the test glob or still hit the same Layout.js parse failure.
Other tests are unaffected (they import non-JSX modules).
Smoke output: see PR description.
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)
Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.
Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.
Pass user={user} to Layout (4 pages, 11 call sites):
- pages/scanner.js (1 call)
- pages/decks.js (3 calls)
- pages/deck-builder.js (4 calls)
- pages/deck/[id].js (3 calls)
(All four still import useAuth from lib/auth-context.js — that's
intentional and stays as-is until the single-auth-provider convoy
collapses the three parallel auth surfaces.)
Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
- pages/profile.js: useState({email: 'me@...', role: 'user', ...})
→ useState(null) + ?. on every sync user.* read
+ early-return guards in getDisplayName/getInitials
+ conditional render around the "Member since" block
so formatDate(undefined) never runs
- pages/settings.js: same pattern (single user.email reader guarded)
Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
- pages/card/[id].js: const user = {email: 'me@...'}
→ const { user } = useAuth() (called unconditionally
at the top of the component; rules-of-hooks safe)
Verification:
- grep 'me@randallstillwell.com' pages/ → 0 hits
- 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
- npm run lint matches baseline (128 problems pre, 128 post; verified
via git stash before/after)
- Manual static read-through of every diff; ReadLints clean on the 7
files
- Dev-server smoke: /cards anonymous returned HTTP 200 with 0
'me@randallstillwell' matches before the user's shared dev server
became unresponsive mid-session (same dev-server-shared-by-user
constraint flagged in Brief 1); interactive logged-in smoke is
parent/operator gated
Flagged-but-deferred (untouched per scope):
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- components/MobileNavigation.js still receives dead user prop
→ cleanup-mobile-nav-dead-props (or fold into god-component-split)
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 15:31:37 -04:00
|
|
|
|
import { useAuth } from '../../lib/use-auth';
|
2025-07-26 01:22:35 -04:00
|
|
|
|
import CollectionSelectionModal from '../../components/CollectionSelectionModal';
|
2025-07-29 15:19:48 -04:00
|
|
|
|
import { ManaCost, ColorIdentity, AdvancedManaCost } from '../../components/ManaSymbols';
|
|
|
|
|
|
import ManaSymbolSettings from '../../components/ManaSymbolSettings';
|
2026-05-29 10:53:40 -04:00
|
|
|
|
import { VOCAB, collectionDisplayName } from '../../lib/collection-vocabulary.js';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
export default function CardDetail() {
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const { id } = router.query;
|
refactor(auth): collapse lib/auth-context.js + lib/admin-auth.js onto lib/use-auth.js (#31)
`lib/use-auth.js` is now the sole client-side auth surface (P1 §9 of
`.convoys/ship-readiness.md`). The legacy `lib/auth-context.js`
(`AuthProvider` + `useAuth`) and `lib/admin-auth.js` (`AdminProvider` +
`useAdmin` + `useIsAdmin`) are deleted; every importer is migrated to
the canonical hook. Pre-convoy a worst-case page mount issued THREE
identical `GET /api/auth/verify` requests (one per provider/hook); the
post-convoy floor is one verify per page mount (3 → 1 on
`pages/card/[id].js`, 2 → 1 elsewhere).
Importer inventory swept (7 source files):
- `pages/_app.js` — removed `<AuthProvider>` wrapper; `<ThemeProvider>`
is now the only top-level provider. `lib/use-auth.js` is hook-only,
no replacement provider needed.
- `pages/index.js`, `pages/scanner.js`, `pages/decks.js`,
`pages/deck/[id].js`, `pages/deck-builder.js` — `import { useAuth }`
path swap from `../lib/auth-context` to `../lib/use-auth`. All five
pages destructured only `{ user }` or `{ user, loading }`; verified
no consumer reads `login` / `register` from useAuth (those flows are
in `pages/login.js` / `pages/signup.js` which call the API directly),
so no shape-parity gap on `lib/use-auth.js`.
- `pages/card/[id].js` — replaced `useIsAdmin()` (the only consumer of
`lib/admin-auth.js` anywhere in the tree) with synchronous
`user?.role === 'admin'` derived from the existing `useAuth()` call.
Render condition at line 524 stays byte-identical.
Decisions documented in `.convoys/single-auth-provider.md`:
- D1: no extension to `lib/use-auth.js` (zero call sites for `login` /
`register` from useAuth — those flows are direct fetches in
`login.js` / `signup.js`).
- D2: `useIsAdmin()` collapses onto `useAuth()`; no separate hook.
- D3: provider tree `<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>`
→ `<ThemeProvider>{children}</ThemeProvider>`.
- D4: 3 → 1 verify roundtrip on `card/[id].js`; 2 → 1 on every other
page-load.
- D5: zero test files modified; the 21-test vitest suite is server-
side or prop-driven (`Layout.test.js` passes `user` as a prop, never
imports the legacy hooks).
Doc / config updates so the deletion lands cleanly:
- `.github/CODEOWNERS` — drop the two CODEOWNERS lines for the deleted
files.
- `AGENTS.md` § 2 architecture row + § 3 "Auth (client)" bullet —
rewritten for the post-convoy single-surface state.
- `.cursor/rules/auth-and-permissions.mdc` — § "Legacy" reframed to
"deleted by this convoy"; § "Authentication state on the client"
updated to the post-convoy `useAuth()` shape and the direct-fetch
login flow used by `login.js` / `signup.js`.
- `.cursor/rules/no-go-zones.mdc` — auth-refactors bullet drops the
deleted files from the canonical list.
- `.cursor/skills/add-page/SKILL.md` — checklist + anti-pattern row
refer to the deletion.
Verification:
- `rg "lib/auth-context|lib/admin-auth" --type js` → 0 hits in source.
- `npm run lint` → 128 → 125 problems (3 fewer errors from the deleted
unused-import lines; no regression).
- `npm run test:run` → 21/21 pass (including the 5 Layout regression
locks from `fix-layout-default-user`, which are prop-driven and
unaffected).
- `npm run build` → all 26 pages compile end-to-end; no SSR / static-
generation breakage that would have surfaced if a page tried to use
the legacy context hook unwrapped.
- Manual smoke deferred to operator post-merge per convoy doc.
Risks (full discussion in convoy file):
- R1 shape parity gap — verified zero consumers of legacy-only
surface; mitigated.
- R2 SSR mismatch from removing `<AuthProvider>` — `useEffect`-
guarded `localStorage` read; identical SSR shape pre/post; build
passes.
- R3 missed importer — post-delete grep + build pass would surface
any miss.
- R5 stale `useAuth` cache across components — pre-existing
pattern, called out as follow-up rather than addressed here.
Out of scope: any change to `lib/permission-middleware.js` (server-
side; resolved P0 #1), `lib/auth-secret.js` (resolved P0 #2),
`pages/api/**` route handlers, login / register API contracts, or
the seeded admin account flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 23:58:08 -04:00
|
|
|
|
const { user, loading: authLoading } = useAuth();
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
const [card, setCard] = useState(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [activeTab, setActiveTab] = useState('details');
|
|
|
|
|
|
const [ownedQuantity, setOwnedQuantity] = useState(0);
|
|
|
|
|
|
const [showQuantityModal, setShowQuantityModal] = useState(false);
|
|
|
|
|
|
const [showCollectionModal, setShowCollectionModal] = useState(false);
|
|
|
|
|
|
const [showDeckModal, setShowDeckModal] = useState(false);
|
|
|
|
|
|
const [selectedCollection, setSelectedCollection] = useState('');
|
|
|
|
|
|
const [selectedDeck, setSelectedDeck] = useState('');
|
|
|
|
|
|
const [quantity, setQuantity] = useState(1);
|
|
|
|
|
|
const [isFavorited, setIsFavorited] = useState(false);
|
2025-07-24 16:03:09 -04:00
|
|
|
|
const [collections, setCollections] = useState([]);
|
|
|
|
|
|
const [decks, setDecks] = useState([]);
|
|
|
|
|
|
const [cardCollections, setCardCollections] = useState([]);
|
|
|
|
|
|
const [cardDecks, setCardDecks] = useState([]);
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
|
// Mana symbol settings
|
|
|
|
|
|
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
|
|
|
|
|
|
|
refactor(auth): collapse lib/auth-context.js + lib/admin-auth.js onto lib/use-auth.js (#31)
`lib/use-auth.js` is now the sole client-side auth surface (P1 §9 of
`.convoys/ship-readiness.md`). The legacy `lib/auth-context.js`
(`AuthProvider` + `useAuth`) and `lib/admin-auth.js` (`AdminProvider` +
`useAdmin` + `useIsAdmin`) are deleted; every importer is migrated to
the canonical hook. Pre-convoy a worst-case page mount issued THREE
identical `GET /api/auth/verify` requests (one per provider/hook); the
post-convoy floor is one verify per page mount (3 → 1 on
`pages/card/[id].js`, 2 → 1 elsewhere).
Importer inventory swept (7 source files):
- `pages/_app.js` — removed `<AuthProvider>` wrapper; `<ThemeProvider>`
is now the only top-level provider. `lib/use-auth.js` is hook-only,
no replacement provider needed.
- `pages/index.js`, `pages/scanner.js`, `pages/decks.js`,
`pages/deck/[id].js`, `pages/deck-builder.js` — `import { useAuth }`
path swap from `../lib/auth-context` to `../lib/use-auth`. All five
pages destructured only `{ user }` or `{ user, loading }`; verified
no consumer reads `login` / `register` from useAuth (those flows are
in `pages/login.js` / `pages/signup.js` which call the API directly),
so no shape-parity gap on `lib/use-auth.js`.
- `pages/card/[id].js` — replaced `useIsAdmin()` (the only consumer of
`lib/admin-auth.js` anywhere in the tree) with synchronous
`user?.role === 'admin'` derived from the existing `useAuth()` call.
Render condition at line 524 stays byte-identical.
Decisions documented in `.convoys/single-auth-provider.md`:
- D1: no extension to `lib/use-auth.js` (zero call sites for `login` /
`register` from useAuth — those flows are direct fetches in
`login.js` / `signup.js`).
- D2: `useIsAdmin()` collapses onto `useAuth()`; no separate hook.
- D3: provider tree `<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>`
→ `<ThemeProvider>{children}</ThemeProvider>`.
- D4: 3 → 1 verify roundtrip on `card/[id].js`; 2 → 1 on every other
page-load.
- D5: zero test files modified; the 21-test vitest suite is server-
side or prop-driven (`Layout.test.js` passes `user` as a prop, never
imports the legacy hooks).
Doc / config updates so the deletion lands cleanly:
- `.github/CODEOWNERS` — drop the two CODEOWNERS lines for the deleted
files.
- `AGENTS.md` § 2 architecture row + § 3 "Auth (client)" bullet —
rewritten for the post-convoy single-surface state.
- `.cursor/rules/auth-and-permissions.mdc` — § "Legacy" reframed to
"deleted by this convoy"; § "Authentication state on the client"
updated to the post-convoy `useAuth()` shape and the direct-fetch
login flow used by `login.js` / `signup.js`.
- `.cursor/rules/no-go-zones.mdc` — auth-refactors bullet drops the
deleted files from the canonical list.
- `.cursor/skills/add-page/SKILL.md` — checklist + anti-pattern row
refer to the deletion.
Verification:
- `rg "lib/auth-context|lib/admin-auth" --type js` → 0 hits in source.
- `npm run lint` → 128 → 125 problems (3 fewer errors from the deleted
unused-import lines; no regression).
- `npm run test:run` → 21/21 pass (including the 5 Layout regression
locks from `fix-layout-default-user`, which are prop-driven and
unaffected).
- `npm run build` → all 26 pages compile end-to-end; no SSR / static-
generation breakage that would have surfaced if a page tried to use
the legacy context hook unwrapped.
- Manual smoke deferred to operator post-merge per convoy doc.
Risks (full discussion in convoy file):
- R1 shape parity gap — verified zero consumers of legacy-only
surface; mitigated.
- R2 SSR mismatch from removing `<AuthProvider>` — `useEffect`-
guarded `localStorage` read; identical SSR shape pre/post; build
passes.
- R3 missed importer — post-delete grep + build pass would surface
any miss.
- R5 stale `useAuth` cache across components — pre-existing
pattern, called out as follow-up rather than addressed here.
Out of scope: any change to `lib/permission-middleware.js` (server-
side; resolved P0 #1), `lib/auth-secret.js` (resolved P0 #2),
`pages/api/**` route handlers, login / register API contracts, or
the seeded admin account flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 23:58:08 -04:00
|
|
|
|
const isAdmin = user?.role === 'admin';
|
|
|
|
|
|
const adminLoading = authLoading;
|
2025-07-24 17:31:29 -04:00
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
// Fetch card data from API
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const fetchCard = async () => {
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/cards/${id}`);
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const cardData = await response.json();
|
|
|
|
|
|
setCard(cardData);
|
|
|
|
|
|
|
2025-07-26 01:32:58 -04:00
|
|
|
|
// Fetch user's ownership of this card
|
|
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ownershipResponse = await fetch(`/api/cards/${id}/ownership`, {
|
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
|
});
|
|
|
|
|
|
if (ownershipResponse.ok) {
|
|
|
|
|
|
const ownershipData = await ownershipResponse.json();
|
|
|
|
|
|
setOwnedQuantity(ownershipData.quantity || 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching ownership:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if card is favorited
|
|
|
|
|
|
try {
|
|
|
|
|
|
const favoritesResponse = await fetch(`/api/favorites?type=card`, {
|
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
|
});
|
|
|
|
|
|
if (favoritesResponse.ok) {
|
|
|
|
|
|
const favoritesData = await favoritesResponse.json();
|
|
|
|
|
|
const isCardFavorited = favoritesData.favorites.some(fav => fav.item_id == id);
|
|
|
|
|
|
setIsFavorited(isCardFavorited);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error checking favorites:', error);
|
|
|
|
|
|
}
|
2025-07-24 16:03:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to fetch card');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching card:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
fetchCard();
|
|
|
|
|
|
}, [id]);
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
// Fetch user's collections and decks
|
2025-07-23 22:26:54 -04:00
|
|
|
|
useEffect(() => {
|
2025-07-24 16:03:09 -04:00
|
|
|
|
const fetchUserData = async () => {
|
|
|
|
|
|
try {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
// Fetch collections
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const collectionsResponse = await fetch('/api/collections', { headers });
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (collectionsResponse.ok) {
|
|
|
|
|
|
const collectionsData = await collectionsResponse.json();
|
|
|
|
|
|
setCollections(collectionsData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch decks
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const decksResponse = await fetch('/api/decks', { headers });
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (decksResponse.ok) {
|
|
|
|
|
|
const decksData = await decksResponse.json();
|
|
|
|
|
|
setDecks(decksData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch card's current collections and decks
|
|
|
|
|
|
if (card) {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const cardCollectionsResponse = await fetch(`/api/cards/${id}/collections`, { headers });
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (cardCollectionsResponse.ok) {
|
|
|
|
|
|
const cardCollectionsData = await cardCollectionsResponse.json();
|
|
|
|
|
|
setCardCollections(cardCollectionsData);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const cardDecksResponse = await fetch(`/api/cards/${id}/decks`, { headers });
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (cardDecksResponse.ok) {
|
|
|
|
|
|
const cardDecksData = await cardDecksResponse.json();
|
|
|
|
|
|
setCardDecks(cardDecksData);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error fetching user data:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (card) {
|
|
|
|
|
|
fetchUserData();
|
2025-07-23 22:26:54 -04:00
|
|
|
|
}
|
2025-07-24 16:03:09 -04:00
|
|
|
|
}, [card, id]);
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
2025-07-27 22:46:48 -04:00
|
|
|
|
const getRarityGradient = (rarity) => {
|
|
|
|
|
|
const rarityKey = rarity?.toLowerCase();
|
2025-07-23 22:26:54 -04:00
|
|
|
|
const gradients = {
|
2025-07-28 09:56:42 -04:00
|
|
|
|
'common': '#9ca3af, #6b7280, #4b5563', // Subtle gray glow
|
|
|
|
|
|
'uncommon': '#10b981, #059669, #047857', // Green glow
|
|
|
|
|
|
'rare': '#f59e0b, #d97706, #b45309', // Gold glow
|
|
|
|
|
|
'mythic': '#fbbf24, #f59e0b, #d97706', // Rich gold glow
|
|
|
|
|
|
'holographic': '#ec4899, #db2777, #be185d', // Pink glow
|
|
|
|
|
|
'enchanted': '#a855f7, #9333ea, #7c3aed', // Purple glow
|
|
|
|
|
|
'super rare': '#3b82f6, #2563eb, #1d4ed8', // Blue glow
|
|
|
|
|
|
'legendary': '#fbbf24, #f59e0b, #ea580c' // Vibrant gold-orange glow
|
2025-07-23 22:26:54 -04:00
|
|
|
|
};
|
2025-07-28 09:56:42 -04:00
|
|
|
|
return gradients[rarityKey] || '#6b7280, #4b5563, #374151';
|
2025-07-27 22:46:48 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getParticleCount = (rarity) => {
|
|
|
|
|
|
const rarityKey = rarity?.toLowerCase();
|
|
|
|
|
|
const particleCounts = {
|
|
|
|
|
|
'common': 0,
|
|
|
|
|
|
'uncommon': 15,
|
|
|
|
|
|
'rare': 25,
|
|
|
|
|
|
'mythic': 40,
|
|
|
|
|
|
'holographic': 50,
|
|
|
|
|
|
'enchanted': 60,
|
|
|
|
|
|
'super rare': 45,
|
|
|
|
|
|
'legendary': 80
|
|
|
|
|
|
};
|
|
|
|
|
|
return particleCounts[rarityKey] || 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getParticleColor = (rarity) => {
|
|
|
|
|
|
const rarityKey = rarity?.toLowerCase();
|
|
|
|
|
|
const colors = {
|
|
|
|
|
|
'common': '#ffffff',
|
|
|
|
|
|
'uncommon': '#10b981',
|
|
|
|
|
|
'rare': '#f59e0b',
|
|
|
|
|
|
'mythic': '#ffd700',
|
|
|
|
|
|
'holographic': '#ff6b6b',
|
|
|
|
|
|
'enchanted': '#a855f7',
|
|
|
|
|
|
'super rare': '#3b82f6',
|
|
|
|
|
|
'legendary': '#ffd700'
|
|
|
|
|
|
};
|
|
|
|
|
|
return colors[rarityKey] || '#ffffff';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getTCGIcon = (game) => {
|
|
|
|
|
|
const icons = {
|
|
|
|
|
|
'MTG': '🔮',
|
|
|
|
|
|
'Pokemon': '⚡',
|
|
|
|
|
|
'Lorcana': '✨'
|
|
|
|
|
|
};
|
|
|
|
|
|
return icons[game] || '🃏';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatCurrency = (amount) => {
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (!amount) return '$0.00';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
return new Intl.NumberFormat('en-US', {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'USD'
|
|
|
|
|
|
}).format(amount);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getRarityLabel = (rarity) => {
|
|
|
|
|
|
const rarityMap = {
|
|
|
|
|
|
'common': 'Common',
|
|
|
|
|
|
'uncommon': 'Uncommon',
|
|
|
|
|
|
'rare': 'Rare',
|
|
|
|
|
|
'mythic': 'Mythic',
|
|
|
|
|
|
'holographic': 'Holographic',
|
2025-07-24 16:03:09 -04:00
|
|
|
|
'enchanted': 'Enchanted',
|
|
|
|
|
|
'super rare': 'Super Rare',
|
|
|
|
|
|
'legendary': 'Legendary'
|
|
|
|
|
|
};
|
|
|
|
|
|
return rarityMap[rarity?.toLowerCase()] || rarity;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getRarityColor = (rarity) => {
|
|
|
|
|
|
const colors = {
|
|
|
|
|
|
'common': '#6B7280',
|
|
|
|
|
|
'uncommon': '#10B981',
|
|
|
|
|
|
'rare': '#F59E0B',
|
|
|
|
|
|
'mythic': '#FFD700',
|
|
|
|
|
|
'holographic': '#FF6B6B',
|
|
|
|
|
|
'enchanted': '#A855F7',
|
|
|
|
|
|
'super rare': '#3B82F6',
|
|
|
|
|
|
'legendary': '#FFD700'
|
2025-07-23 22:26:54 -04:00
|
|
|
|
};
|
2025-07-24 16:03:09 -04:00
|
|
|
|
return colors[rarity?.toLowerCase()] || '#6B7280';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOwnershipUpdate = async (newQuantity) => {
|
|
|
|
|
|
try {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const token = localStorage.getItem('auth_token');
|
2025-07-24 16:03:09 -04:00
|
|
|
|
const response = await fetch(`/api/cards/${id}/ownership`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
2025-07-26 01:32:58 -04:00
|
|
|
|
'Authorization': `Bearer ${token}`
|
2025-07-24 16:03:09 -04:00
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ quantity: newQuantity })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
setOwnedQuantity(newQuantity);
|
|
|
|
|
|
setShowQuantityModal(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to update ownership');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error updating ownership:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleAddToCollection = async () => {
|
|
|
|
|
|
try {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
const response = await fetch(`/api/cards/${id}/collections`, {
|
|
|
|
|
|
method: 'POST',
|
2025-07-26 01:32:58 -04:00
|
|
|
|
headers,
|
2025-07-24 16:03:09 -04:00
|
|
|
|
body: JSON.stringify({ collectionId: selectedCollection })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Refresh card collections
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const cardCollectionsResponse = await fetch(`/api/cards/${id}/collections`, {
|
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
|
});
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (cardCollectionsResponse.ok) {
|
|
|
|
|
|
const cardCollectionsData = await cardCollectionsResponse.json();
|
|
|
|
|
|
setCardCollections(cardCollectionsData);
|
|
|
|
|
|
}
|
|
|
|
|
|
setShowCollectionModal(false);
|
|
|
|
|
|
setSelectedCollection('');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to add to collection');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding to collection:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleAddToDeck = async () => {
|
|
|
|
|
|
try {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
'Authorization': `Bearer ${token}`
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
const response = await fetch(`/api/cards/${id}/decks`, {
|
|
|
|
|
|
method: 'POST',
|
2025-07-26 01:32:58 -04:00
|
|
|
|
headers,
|
2025-07-24 16:03:09 -04:00
|
|
|
|
body: JSON.stringify({ deckId: selectedDeck })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
// Refresh card decks
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const cardDecksResponse = await fetch(`/api/cards/${id}/decks`, {
|
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
|
});
|
2025-07-24 16:03:09 -04:00
|
|
|
|
if (cardDecksResponse.ok) {
|
|
|
|
|
|
const cardDecksData = await cardDecksResponse.json();
|
|
|
|
|
|
setCardDecks(cardDecksData);
|
|
|
|
|
|
}
|
|
|
|
|
|
setShowDeckModal(false);
|
|
|
|
|
|
setSelectedDeck('');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to add to deck');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding to deck:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleToggleFavorite = async () => {
|
|
|
|
|
|
try {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const token = localStorage.getItem('auth_token');
|
2025-07-24 16:03:09 -04:00
|
|
|
|
const response = await fetch(`/api/cards/${id}/favorite`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
2025-07-26 01:32:58 -04:00
|
|
|
|
'Authorization': `Bearer ${token}`
|
2025-07-24 16:03:09 -04:00
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({ favorited: !isFavorited })
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
setIsFavorited(!isFavorited);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('Failed to toggle favorite');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error toggling favorite:', error);
|
|
|
|
|
|
}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout user={user}>
|
|
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2" style={{ borderColor: 'var(--text-accent-light)' }}></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!card) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout user={user}>
|
|
|
|
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-6xl mb-4">🃏</div>
|
|
|
|
|
|
<h2 className="text-2xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Card Not Found
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
The card you're looking for doesn't exist.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => router.push('/cards')}
|
|
|
|
|
|
className="px-6 py-3 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
|
|
|
|
|
>
|
|
|
|
|
|
Back to Cards
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Layout user={user}>
|
2025-07-27 22:36:40 -04:00
|
|
|
|
<div style={{ backgroundColor: 'var(--bg-primary)', minHeight: '100vh' }}>
|
|
|
|
|
|
{/* Hero Section with Card Image and Basic Info */}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div
|
|
|
|
|
|
className="relative min-h-96 flex items-center justify-center overflow-hidden"
|
|
|
|
|
|
style={{
|
2025-07-28 09:56:42 -04:00
|
|
|
|
background: 'linear-gradient(135deg, var(--bg-secondary), var(--bg-primary))'
|
2025-07-23 22:26:54 -04:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-07-27 22:46:48 -04:00
|
|
|
|
{/* Animated Particles Background */}
|
|
|
|
|
|
{getParticleCount(card.rarity) > 0 && (
|
|
|
|
|
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
|
|
|
|
{Array.from({ length: getParticleCount(card.rarity) }).map((_, i) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
className="absolute animate-float"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
left: `${Math.random() * 100}%`,
|
|
|
|
|
|
top: `${Math.random() * 100}%`,
|
|
|
|
|
|
animationDelay: `${Math.random() * 3}s`,
|
|
|
|
|
|
animationDuration: `${3 + Math.random() * 2}s`
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="w-1 h-1 rounded-full opacity-60"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: getParticleColor(card.rarity),
|
|
|
|
|
|
boxShadow: `0 0 6px ${getParticleColor(card.rarity)}`
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-07-23 22:26:54 -04:00
|
|
|
|
{/* Background Pattern */}
|
2025-07-27 22:46:48 -04:00
|
|
|
|
<div className="absolute inset-0 opacity-5">
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="absolute inset-0" style={{
|
2025-07-27 22:46:48 -04:00
|
|
|
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000000' fill-opacity='0.1'%3E%3Ccircle cx='30' cy='30' r='2'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
|
2025-07-23 22:26:54 -04:00
|
|
|
|
}}></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="relative z-10 container mx-auto px-6 py-12">
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
|
|
|
|
|
{/* Card Image */}
|
|
|
|
|
|
<div className="flex justify-center">
|
|
|
|
|
|
<div className="relative group">
|
2025-07-28 09:56:42 -04:00
|
|
|
|
{/* Rarity Glow Effect */}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div
|
2025-07-28 09:56:42 -04:00
|
|
|
|
className="absolute inset-0 rounded-2xl blur-xl opacity-60 animate-pulse"
|
2025-07-23 22:26:54 -04:00
|
|
|
|
style={{
|
2025-07-28 09:56:42 -04:00
|
|
|
|
background: `linear-gradient(135deg, ${getRarityGradient(card.rarity)})`,
|
|
|
|
|
|
transform: 'scale(1.1)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="relative w-80 h-112 rounded-2xl overflow-hidden shadow-2xl transform transition-all duration-300 hover:scale-105"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: `linear-gradient(135deg, ${getRarityColor(card.rarity)}15, ${getRarityColor(card.rarity)}08)`,
|
|
|
|
|
|
boxShadow: `0 20px 40px rgba(0, 0, 0, 0.3), 0 0 30px ${getRarityColor(card.rarity)}40`
|
2025-07-23 22:26:54 -04:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{card.image_url ? (
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.image_url}
|
|
|
|
|
|
alt={card.name}
|
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="w-full h-full flex items-center justify-center text-white">
|
|
|
|
|
|
<div className="text-center p-8">
|
|
|
|
|
|
<div className="text-6xl mb-4">{getTCGIcon(card.game)}</div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold mb-2">{card.name}</h1>
|
|
|
|
|
|
<p className="text-sm opacity-90">{card.set_name}</p>
|
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
|
<span className="px-3 py-1 rounded-full text-xs font-medium bg-white bg-opacity-20 backdrop-blur-sm">
|
|
|
|
|
|
{getRarityLabel(card.rarity)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Card Info */}
|
2025-07-28 09:56:42 -04:00
|
|
|
|
<div style={{ color: 'var(--text-primary)' }}>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="mb-6">
|
|
|
|
|
|
<h1 className="text-4xl font-bold mb-2">{card.name}</h1>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<p className="text-xl opacity-90 mb-4">{card.oracle_text || card.card_type}</p>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Ownership Status and Actions */}
|
|
|
|
|
|
<div className="mb-4 p-4 rounded-xl bg-white bg-opacity-10 backdrop-blur-sm">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
|
<span className="font-semibold">Ownership Status</span>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{ownedQuantity > 0 && (
|
|
|
|
|
|
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-500 bg-opacity-80">
|
2026-05-29 10:53:40 -04:00
|
|
|
|
In {VOCAB.MY_COLLECTION} ({ownedQuantity})
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
2025-07-24 16:03:09 -04:00
|
|
|
|
onClick={handleToggleFavorite}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
className={`p-2 rounded-full transition-all duration-200 ${
|
|
|
|
|
|
isFavorited
|
|
|
|
|
|
? 'bg-red-500 bg-opacity-80 text-white'
|
|
|
|
|
|
: 'bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isFavorited ? '❤️' : '🤍'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowQuantityModal(true)}
|
|
|
|
|
|
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>📦</span>
|
2026-05-29 10:53:40 -04:00
|
|
|
|
{ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowCollectionModal(true)}
|
|
|
|
|
|
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>📁</span>
|
2026-05-29 10:53:40 -04:00
|
|
|
|
{VOCAB.ADD_TO_LIST}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowDeckModal(true)}
|
|
|
|
|
|
className="px-4 py-2 rounded-xl font-medium bg-white bg-opacity-20 backdrop-blur-sm hover:bg-opacity-30 transition-all duration-200 flex items-center gap-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>🎴</span>
|
|
|
|
|
|
Add to Deck
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-4 mb-6">
|
|
|
|
|
|
<span className="text-3xl">{getTCGIcon(card.game)}</span>
|
|
|
|
|
|
<span className="text-lg">{card.game}</span>
|
|
|
|
|
|
<span className="px-3 py-1 rounded-full text-sm font-medium bg-white bg-opacity-20 backdrop-blur-sm">
|
|
|
|
|
|
{getRarityLabel(card.rarity)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-3xl font-bold gradient-text-gold">
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{formatCurrency(card.current_price || 0)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
2025-07-24 17:31:29 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Admin Edit Button */}
|
|
|
|
|
|
{isAdmin && !adminLoading && (
|
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => router.push(`/admin/card-editor?id=${card.id}`)}
|
|
|
|
|
|
className="px-4 py-2 rounded-xl font-medium bg-orange-500 hover:bg-orange-600 text-white transition-all duration-200 flex items-center gap-2 shadow-lg hover:shadow-xl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>✏️</span>
|
|
|
|
|
|
Edit Card (Admin)
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{/* Current Collections and Decks */}
|
|
|
|
|
|
{(cardCollections.length > 0 || cardDecks.length > 0) && (
|
|
|
|
|
|
<div className="mb-6 p-4 rounded-xl bg-white bg-opacity-10 backdrop-blur-sm">
|
|
|
|
|
|
<h4 className="font-semibold mb-3">Currently In:</h4>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{cardCollections.map(collection => (
|
|
|
|
|
|
<div key={collection.id} className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<span>📁</span>
|
2026-05-29 10:53:40 -04:00
|
|
|
|
<span>{collectionDisplayName(collection)}</span>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{cardDecks.map(deck => (
|
|
|
|
|
|
<div key={deck.id} className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<span>🎴</span>
|
|
|
|
|
|
<span>{deck.name}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content Tabs */}
|
2025-07-27 22:36:40 -04:00
|
|
|
|
<div
|
|
|
|
|
|
className="container mx-auto px-6 py-8"
|
|
|
|
|
|
style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }}
|
|
|
|
|
|
>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="flex border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
{['details', 'price-history', 'purchase'].map((tab) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={tab}
|
|
|
|
|
|
onClick={() => setActiveTab(tab)}
|
|
|
|
|
|
className={`px-6 py-3 font-medium transition-all duration-200 ${
|
|
|
|
|
|
activeTab === tab
|
|
|
|
|
|
? 'border-b-2 font-semibold'
|
|
|
|
|
|
: 'hover:opacity-70'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
color: activeTab === tab ? 'var(--text-accent)' : 'var(--text-secondary)',
|
|
|
|
|
|
borderColor: activeTab === tab ? 'var(--text-accent)' : 'transparent'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab === 'details' && 'Card Details'}
|
|
|
|
|
|
{tab === 'price-history' && 'Price History'}
|
|
|
|
|
|
{tab === 'purchase' && 'Purchase Options'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Tab Content */}
|
|
|
|
|
|
<div className="mt-8">
|
|
|
|
|
|
{activeTab === 'details' && (
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
|
|
|
|
{/* Card Metadata */}
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Card Information
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>TCG</span>
|
|
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{card.game}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Set</span>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{card.set_name}</span>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Card Number</span>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{card.card_number}</span>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Type</span>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{card.card_type}</span>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Rarity</span>
|
|
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{getRarityLabel(card.rarity)}</span>
|
|
|
|
|
|
</div>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{card.mana_cost && (
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Cost to Play</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
<ManaCost cost={card.mana_cost} size="md" useSVG={manaSymbolSettings.useSVG} />
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.power && (
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Power</span>
|
|
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{card.power}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.toughness && (
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Toughness</span>
|
|
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{card.toughness}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.current_price && (
|
|
|
|
|
|
<div className="flex justify-between py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
|
<span style={{ color: 'var(--text-secondary)' }}>Current Price</span>
|
|
|
|
|
|
<span style={{ color: 'var(--text-primary)' }}>{formatCurrency(card.current_price)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{/* Card Text */}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Card Text
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="space-y-4">
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{card.oracle_text && (
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="p-4 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<p style={{ color: 'var(--text-primary)' }}>{card.oracle_text}</p>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{activeTab === 'price-history' && (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Price History
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{/* Price Graph Placeholder */}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
<div className="p-6 rounded-xl" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
|
|
|
|
|
<h4 className="text-lg font-semibold mb-6" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Price Trend
|
|
|
|
|
|
</h4>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<div className="h-64 flex items-center justify-center">
|
|
|
|
|
|
<p style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
Price history data will be available soon
|
|
|
|
|
|
</p>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Price Statistics Cards */}
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
|
|
|
|
{/* Current Price */}
|
|
|
|
|
|
<div className="p-6 rounded-xl border transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="text-2xl mb-2">💰</div>
|
|
|
|
|
|
<h5 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Current Price
|
|
|
|
|
|
</h5>
|
|
|
|
|
|
<div className="text-xl font-bold gradient-text-gold">
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{formatCurrency(card.current_price || 0)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{activeTab === 'purchase' && (
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Where to Buy
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
2025-07-24 16:03:09 -04:00
|
|
|
|
<a
|
|
|
|
|
|
href={`https://www.tcgplayer.com/search/${card.game.toLowerCase()}/product?productName=${encodeURIComponent(card.name)}`}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
|
className="p-6 rounded-xl border transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<span className="text-2xl">🃏</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
TCGPlayer
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
View on TCGPlayer
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a
|
|
|
|
|
|
href={`https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card.name + ' ' + card.game)}`}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
|
className="p-6 rounded-xl border transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<span className="text-2xl">🛒</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
eBay
|
|
|
|
|
|
</h4>
|
|
|
|
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
View on eBay
|
|
|
|
|
|
</p>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</a>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Quantity Modal */}
|
|
|
|
|
|
{showQuantityModal && (
|
|
|
|
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
|
|
|
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
|
|
|
|
|
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
2026-05-29 10:53:40 -04:00
|
|
|
|
{ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Quantity
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
|
|
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center border transition-all duration-200 hover:shadow-md"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-primary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
-
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="text-2xl font-bold px-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
{quantity}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setQuantity(quantity + 1)}
|
|
|
|
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center border transition-all duration-200 hover:shadow-md"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-primary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
|
<button
|
2025-07-24 16:03:09 -04:00
|
|
|
|
onClick={() => handleOwnershipUpdate(quantity)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
|
|
|
|
|
>
|
2026-05-29 10:53:40 -04:00
|
|
|
|
{ownedQuantity > 0 ? 'Update' : VOCAB.ADD_TO_MY_COLLECTION}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setShowQuantityModal(false)}
|
|
|
|
|
|
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-secondary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-07-26 01:22:35 -04:00
|
|
|
|
{/* Collection Selection Modal */}
|
|
|
|
|
|
<CollectionSelectionModal
|
|
|
|
|
|
isOpen={showCollectionModal}
|
|
|
|
|
|
onClose={() => setShowCollectionModal(false)}
|
|
|
|
|
|
cards={card ? [card] : []}
|
|
|
|
|
|
onAddToCollections={(results, selectedCollectionIds, cards) => {
|
|
|
|
|
|
const successCount = results.filter(r => r.success).length;
|
|
|
|
|
|
if (successCount > 0) {
|
|
|
|
|
|
// Refresh card collections
|
|
|
|
|
|
const fetchCardCollections = async () => {
|
|
|
|
|
|
try {
|
2025-07-26 01:32:58 -04:00
|
|
|
|
const token = localStorage.getItem('auth_token');
|
|
|
|
|
|
const response = await fetch(`/api/cards/${id}/collections`, {
|
|
|
|
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
|
|
|
|
});
|
2025-07-26 01:22:35 -04:00
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
setCardCollections(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error refreshing card collections:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
fetchCardCollections();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
|
|
{/* Deck Modal */}
|
|
|
|
|
|
{showDeckModal && (
|
|
|
|
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
|
|
|
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
|
|
|
|
|
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Add to Deck
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="mb-6">
|
|
|
|
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
|
Select Deck
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedDeck}
|
|
|
|
|
|
onChange={(e) => setSelectedDeck(e.target.value)}
|
|
|
|
|
|
className="w-full p-3 rounded-xl border transition-all duration-200"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: 'var(--bg-secondary)',
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-primary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Choose a deck...</option>
|
2025-07-24 16:03:09 -04:00
|
|
|
|
{decks
|
2025-07-23 22:26:54 -04:00
|
|
|
|
.filter(deck => deck.game === card.game)
|
|
|
|
|
|
.map(deck => (
|
|
|
|
|
|
<option key={deck.id} value={deck.id}>
|
|
|
|
|
|
{deck.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
|
<button
|
2025-07-24 16:03:09 -04:00
|
|
|
|
onClick={handleAddToDeck}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
disabled={!selectedDeck}
|
|
|
|
|
|
className={`flex-1 px-4 py-2 rounded-xl font-medium transition-all duration-200 ${
|
|
|
|
|
|
selectedDeck
|
|
|
|
|
|
? 'gradient-bg-purple text-white hover:shadow-lg'
|
|
|
|
|
|
: 'opacity-50 cursor-not-allowed'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
Add to Deck
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setShowDeckModal(false);
|
|
|
|
|
|
setSelectedDeck('');
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="flex-1 px-4 py-2 rounded-xl font-medium border transition-all duration-200"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
borderColor: 'var(--border)',
|
|
|
|
|
|
color: 'var(--text-secondary)'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-07-27 22:36:40 -04:00
|
|
|
|
</div>
|
2025-07-23 22:26:54 -04:00
|
|
|
|
</Layout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|