Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 1 addition & 19 deletions backend/apps/admin/src/components/GraderGradeSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Label, Select } from '@medusajs/ui';

// Client mirror of PSA's canonical 11-point scale (backend source of truth:
// packages/api/src/api/admin/media/label.ts PSA_GRADES — keep in sync).
// Qualifier half-grades (2.5–9.5) deliberately excluded (§3a): the catalog
// doesn't carry them, and 9.5 is a PriceCharting tier, never a PSA grade.
// 1.5 stays — PSA's base FR grade.
const PSA_GRADES = [
'10',
'9',
'8',
'7',
'6',
'5',
'4',
'3',
'2',
'1.5',
'1',
] as const;
import { PSA_GRADES } from '../lib/format';

const GRADERS = ['PSA', 'BGS', 'CGC', 'SGC'] as const;
const NONE = '__none__'; // @medusajs/ui Select rejects '' as an item value
Expand Down
48 changes: 48 additions & 0 deletions backend/apps/admin/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,54 @@
"queueSkippedCapped": "{{n}} created, {{skipped}} skipped. Errors not shown: {{hidden}}."
}
},
"pcCollection": {
"title": "Add from PriceCharting collection",
"subtitle": "Scan your own PriceCharting collection and onboard many holdings in one pass. It runs to thousands of items, so the scan pages through with live counters and can be stopped as soon as what you want is on screen. Importing re-reads each pick at its live per-grade value (not the collection valuation); you then set units held and link a pixel Pokémon before saving the batch.",
"fetch": "Scan my collection",
"refetch": "Rescan from the start",
"stop": "Stop scan",
"continue": "Continue scan",
"progress": "{{scanned}} scanned ({{pages}} pages) · {{shown}} shown",
"complete": "Whole collection scanned",
"partial": "Partial — scan stopped early",
"empty": "No holdings came back from PriceCharting.",
"filter": "Filter by name, set or grade tag…",
"cardsOnly": "Cards only",
"gradedOnly": "Graded only",
"selectAll": "Select all shown",
"deselectAll": "Deselect all shown",
"import": "Import selected ({{n}})",
"importing": "Pricing {{done}} / {{total}}…",
"tierUnmapped": "Pick after import",
"pickTier": "Pick a grade tier",
"valueHint": "Value shown is PriceCharting's valuation of your holding, for triage only. Import re-reads the live per-grade price so it matches what the nightly sync writes.",
"drafts": "Imported ({{n}}) — finish these before saving",
"slabLine": "PriceCharting tag {{tag}} · recorded as {{grader}}",
"noGrader": "no grader (tier is a price comp)",
"remove": "Remove import",
"save": "Save {{n}} products",
"col": {
"item": "Item",
"pcTag": "PriceCharting tag",
"tier": "Grade tier",
"offerValue": "Holding value"
},
"skip": {
"noImage": "{{name}}: PriceCharting has no card photo — add it on the search page.",
"noPrices": "{{name}}: PriceCharting lists no prices for this product."
},
"toast": {
"imported": "{{n}} imported — set units and pixel Pokémon, then save.",
"skipped": "{{skipped}} skipped.",
"skippedCapped": "{{skipped}} skipped. Errors not shown: {{hidden}}."
},
"stopImport": "Stop import",
"rowCap": "Showing the first {{shown}} of {{total}} matches — narrow the filter to reach the rest.",
"merged": "{{n}} holdings of this card at this grade — one product, units preset to {{n}}.",
"selectRow": "Select {{name}} ({{tag}})",
"clearSelection": "Clear selection ({{n}})",
"foreignDropped": "{{n}} rows were not yours and were dropped — PRICECHARTING_SELLER_ID does not match the account the API token reads."
},
"cards": {
"title": "Gacha Cards",
"subtitle": "Inventory products registered as gacha prizes. Add the item to the product catalog first, then register it here; rarity is chosen per pack in each pack's win-rate editor.",
Expand Down
39 changes: 39 additions & 0 deletions backend/apps/admin/src/lib/admin-rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,45 @@ export async function getPriceChartingProduct(id: string): Promise<PcProduct> {
return data.product;
}

// One holding in the operator's own PriceCharting collection. Carries no FMV:
// `value_usd` is PriceCharting's valuation of that offer, shown for triage only —
// the import re-reads the per-grade price so market_value matches what the
// nightly sync will write. A 503 means PRICECHARTING_API_TOKEN is not
// configured; surface the message and use the search page instead.
export interface PcOffer {
/** Per-row id — always present; the API drops rows without one. */
offer_id: string;
product_id: string;
name: string;
set: string;
/** PriceCharting's own grade tag, verbatim ("PSA 10", "Graded 9"). */
include: string;
condition: string;
value_usd: number | null;
/** `include` mapped to a priceable grade tier, or null — operator picks. */
grade: string | null;
image: string | null;
/** Upstream `is-card`: a real collection also holds games and hardware. */
is_card: boolean;
}

/** One page (~30) of the collection plus the cursor for the next one; an empty
* cursor means the collection is exhausted. Paged rather than walked server
* side because the collection runs to five figures. */
export async function getPriceChartingCollection(
cursor = '',
): Promise<{ offers: PcOffer[]; cursor: string; foreign_dropped: number }> {
return getJson<{
offers: PcOffer[];
cursor: string;
/** Rows upstream said belong to another account; the API dropped them. */
foreign_dropped: number;
}>(
'/admin/pricecharting/collection' +
(cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''),
);
}

// §7a label prefill: year (set release) + note (rarity) from pokemontcg.io,
// proxied server-side. Always resolves — a lookup miss/outage returns nulls,
// never throws (see api/admin/tcg/tcg-meta.ts).
Expand Down
41 changes: 36 additions & 5 deletions backend/apps/admin/src/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fmtPct,
usdToMyr,
gradeToGrader,
graderFromInclude,
orderDateTime,
deliveryStatusLabel,
} from './format';
Expand Down Expand Up @@ -50,13 +51,19 @@ describe('timeAgo', () => {
expect(timeAgo(new Date(now - 30_000).toISOString(), now)).toBe('just now');
});
it('returns whole minutes', () => {
expect(timeAgo(new Date(now - 5 * 60_000).toISOString(), now)).toBe('5m ago');
expect(timeAgo(new Date(now - 5 * 60_000).toISOString(), now)).toBe(
'5m ago',
);
});
it('returns whole hours', () => {
expect(timeAgo(new Date(now - 3 * 3_600_000).toISOString(), now)).toBe('3h ago');
expect(timeAgo(new Date(now - 3 * 3_600_000).toISOString(), now)).toBe(
'3h ago',
);
});
it('returns whole days', () => {
expect(timeAgo(new Date(now - 2 * 86_400_000).toISOString(), now)).toBe('2d ago');
expect(timeAgo(new Date(now - 2 * 86_400_000).toISOString(), now)).toBe(
'2d ago',
);
});
it('returns an em dash for an invalid ISO string', () => {
expect(timeAgo('not-a-date', now)).toBe('—');
Expand All @@ -65,10 +72,14 @@ describe('timeAgo', () => {
expect(timeAgo(new Date(now - 60_000).toISOString(), now)).toBe('1m ago');
});
it('returns "1h ago" at exactly 60 minutes', () => {
expect(timeAgo(new Date(now - 3_600_000).toISOString(), now)).toBe('1h ago');
expect(timeAgo(new Date(now - 3_600_000).toISOString(), now)).toBe(
'1h ago',
);
});
it('returns "1d ago" at exactly 24 hours', () => {
expect(timeAgo(new Date(now - 86_400_000).toISOString(), now)).toBe('1d ago');
expect(timeAgo(new Date(now - 86_400_000).toISOString(), now)).toBe(
'1d ago',
);
});
it('clamps a future timestamp to "just now"', () => {
expect(timeAgo(new Date(now + 5_000).toISOString(), now)).toBe('just now');
Expand Down Expand Up @@ -154,6 +165,26 @@ describe('orderDateTime', () => {
});
});

describe('graderFromInclude', () => {
it('keeps the grader a collection offer asserts', () => {
expect(graderFromInclude('PSA 9')).toEqual({ grader: 'PSA', grade: '9' });
expect(graderFromInclude('bgs10')).toEqual({ grader: 'BGS', grade: '10' });
expect(graderFromInclude(' cgc 8 ')).toEqual({ grader: 'CGC', grade: '8' });
});

it('drops half grades — the catalog scale has no 9.5', () => {
expect(graderFromInclude('BGS 9.5')).toEqual({ grader: '', grade: '' });
expect(graderFromInclude('PSA 8.5')).toEqual({ grader: '', grade: '' });
});

it('asserts nothing from a tag that names no grader', () => {
expect(graderFromInclude('Loose')).toEqual({ grader: '', grade: '' });
expect(graderFromInclude('Grade 9')).toEqual({ grader: '', grade: '' });
expect(graderFromInclude('Gem Mint')).toEqual({ grader: '', grade: '' });
expect(graderFromInclude('')).toEqual({ grader: '', grade: '' });
});
});

describe('fmtPct', () => {
it('formats an integer without decimals', () => {
expect(fmtPct(20)).toBe('20%');
Expand Down
48 changes: 47 additions & 1 deletion backend/apps/admin/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,53 @@ export const fmtPct = (n: number): string =>
// wired through a new workspace package. Keep in sync if the backend changes.
// Used by both the from-PriceCharting page and the register-card modal to
// derive an operator-facing grader/grade suggestion from a PC tier label.
export function gradeToGrader(label: string): { grader: string; grade: string } {
// PSA's canonical 11-point scale (backend source of truth:
// packages/api/src/api/admin/media/label.ts PSA_GRADES — keep in sync).
// Qualifier half-grades (2.5–9.5) deliberately excluded (§3a): the catalog
// doesn't carry them, and 9.5 is a PriceCharting tier, never a PSA grade.
// 1.5 stays — PSA's base FR grade.
export const PSA_GRADES = [
'10',
'9',
'8',
'7',
'6',
'5',
'4',
'3',
'2',
'1.5',
'1',
] as const;

/**
* Grader + grade asserted by a PriceCharting collection offer's own grade tag
* ("PSA 9"). Unlike a search hit — where PriceCharting supplies only a price
* COMP and the operator states the slab — a collection offer IS the slab, so
* the tag is ground truth worth keeping even though it prices off the generic
* "Grade 9" field.
*
* Half grades return blank on purpose: PSA_GRADES carries no 8.5/9.5, so a
* "BGS 9.5" offer imports grader-less rather than minting an off-scale grade
* the card editor would render as legacy.
*/
export function graderFromInclude(include: string): {
grader: string;
grade: string;
} {
const m = /^\s*(psa|bgs|cgc|sgc)\s*(\d+(?:\.5)?)\s*$/i.exec(include);
if (!m) return { grader: '', grade: '' };
const grade = m[2];
if (!PSA_GRADES.includes(grade as (typeof PSA_GRADES)[number])) {
return { grader: '', grade: '' };
}
return { grader: m[1].toUpperCase(), grade };
}

export function gradeToGrader(label: string): {
grader: string;
grade: string;
} {
for (const g of ['PSA', 'BGS', 'CGC', 'SGC']) {
if (label.startsWith(g + ' ')) {
return { grader: g, grade: label.slice(g.length + 1) };
Expand Down
16 changes: 12 additions & 4 deletions backend/apps/admin/src/routes/cards/CardPokemonFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ type Props = {
currentDex?: number | null;
/** Card/product title — the default name for a custom-uploaded entry. */
suggestionName: string;
/** Namespaces this instance's DOM ids. REQUIRED when more than one picker is
* on screen (the bulk collection import renders one per draft): without it
* every instance shares `card-pokemon-search`, so clicking any label focuses
* the FIRST input and every aria-controls points at the first listbox. */
idPrefix?: string;
};

const PICKER_LIMIT = 40;
Expand All @@ -46,7 +51,10 @@ const CardPokemonFields = ({
currentSprite,
currentDex,
suggestionName,
idPrefix = 'card',
}: Props) => {
const searchId = `${idPrefix}-pokemon-search`;
const listboxId = `${idPrefix}-pokemon-listbox`;
const [search, setSearch] = useState('');
// The entry chosen THIS session — drives the preview once picked. On first
// load it's null and the preview falls back to the card's mirrored sprite.
Expand Down Expand Up @@ -174,7 +182,7 @@ const CardPokemonFields = ({

return (
<div className="bg-ui-bg-subtle flex flex-col gap-y-3 rounded-lg p-4">
<Label size="small" weight="plus" htmlFor="card-pokemon-search">
<Label size="small" weight="plus" htmlFor={searchId}>
Pixel Pokémon
</Label>

Expand Down Expand Up @@ -207,18 +215,18 @@ const CardPokemonFields = ({
which fires too late to beat Radix's Escape-to-close). */}
<Input
ref={searchRef}
id="card-pokemon-search"
id={searchId}
placeholder="Search the Pokédex library by name…"
aria-label="Search the Pokédex library by name"
role="combobox"
aria-expanded={q.length >= 1}
aria-controls="card-pokemon-listbox"
aria-controls={listboxId}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{q.length >= 1 && (
<div
id="card-pokemon-listbox"
id={listboxId}
role="listbox"
aria-label="Library matches"
aria-busy={isFetching}
Expand Down
Loading