Compare commits
5 Commits
feat/inven
...
17465b13c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17465b13c1 | ||
|
|
c61cafecdc | ||
|
|
2b3d5f322e | ||
|
|
53cdddb183 | ||
| 35c8bf25f5 |
@@ -12,7 +12,7 @@ async function findMissingImages() {
|
|||||||
.where(sql`${schema.tcgcards.sealed} = false`);
|
.where(sql`${schema.tcgcards.sealed} = false`);
|
||||||
const missingImages: string[] = [];
|
const missingImages: string[] = [];
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${card.productId}.jpg`);
|
const imagePath = path.join(process.cwd(), 'static', 'cards', `${card.productId}.jpg`);
|
||||||
try {
|
try {
|
||||||
await fs.access(imagePath);
|
await fs.access(imagePath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const createCardCollection = async () => {
|
|||||||
{ name: 'productLineName', type: 'string', facet: true },
|
{ name: 'productLineName', type: 'string', facet: true },
|
||||||
{ name: 'rarityName', type: 'string', facet: true },
|
{ name: 'rarityName', type: 'string', facet: true },
|
||||||
{ name: 'setName', type: 'string', facet: true },
|
{ name: 'setName', type: 'string', facet: true },
|
||||||
|
{ name: 'setCode', type: 'string' },
|
||||||
{ name: 'cardType', type: 'string', facet: true },
|
{ name: 'cardType', type: 'string', facet: true },
|
||||||
{ name: 'energyType', type: 'string', facet: true },
|
{ name: 'energyType', type: 'string', facet: true },
|
||||||
{ name: 'number', type: 'string', sort: true },
|
{ name: 'number', type: 'string', sort: true },
|
||||||
@@ -94,7 +95,15 @@ export const upsertCardCollection = async (db:DBInstance) => {
|
|||||||
with: { set: true, tcgdata: true, prices: true },
|
with: { set: true, tcgdata: true, prices: true },
|
||||||
});
|
});
|
||||||
await client.collections('cards').documents().import(pokemon.map(card => {
|
await client.collections('cards').documents().import(pokemon.map(card => {
|
||||||
const marketPrice = card.tcgdata?.marketPrice ? DollarToInt(card.tcgdata.marketPrice) : null;
|
// Use the NM SKU price matching the card's variant (kept fresh by syncPrices)
|
||||||
|
// Fall back to any NM sku, then to tcgdata price
|
||||||
|
const nmSku = card.prices.find(p => p.condition === 'Near Mint' && p.variant === card.variant)
|
||||||
|
?? card.prices.find(p => p.condition === 'Near Mint');
|
||||||
|
const marketPrice = nmSku?.marketPrice
|
||||||
|
? DollarToInt(nmSku.marketPrice)
|
||||||
|
: card.tcgdata?.marketPrice
|
||||||
|
? DollarToInt(card.tcgdata.marketPrice)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: card.cardId.toString(),
|
id: card.cardId.toString(),
|
||||||
@@ -105,12 +114,13 @@ export const upsertCardCollection = async (db:DBInstance) => {
|
|||||||
productLineName: card.productLineName,
|
productLineName: card.productLineName,
|
||||||
rarityName: card.rarityName,
|
rarityName: card.rarityName,
|
||||||
setName: card.set?.setName || "",
|
setName: card.set?.setName || "",
|
||||||
|
setCode: card.set?.setCode || "",
|
||||||
cardType: card.cardType || "",
|
cardType: card.cardType || "",
|
||||||
energyType: card.energyType || "",
|
energyType: card.energyType || "",
|
||||||
number: card.number,
|
number: card.number,
|
||||||
Artist: card.artist || "",
|
Artist: card.artist || "",
|
||||||
sealed: card.sealed,
|
sealed: card.sealed,
|
||||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
content: [card.productName, card.productLineName, card.set?.setName || "", card.set?.setCode || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||||
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||||
...(marketPrice !== null && { marketPrice }),
|
...(marketPrice !== null && { marketPrice }),
|
||||||
sku_id: card.prices.map(price => price.skuId.toString())
|
sku_id: card.prices.map(price => price.skuId.toString())
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ async function syncProductLine(productLine: string, field: string, fieldValue: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get image if it doesn't already exist
|
// get image if it doesn't already exist
|
||||||
const imagePath = path.join(process.cwd(), 'public', 'cards', `${item.productId}.jpg`);
|
const imagePath = path.join(process.cwd(), 'static', 'cards', `${item.productId}.jpg`);
|
||||||
if (!await helper.FileExists(imagePath)) {
|
if (!await helper.FileExists(imagePath)) {
|
||||||
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
const imageResponse = await fetch(`https://tcgplayer-cdn.tcgplayer.com/product/${item.productId}_in_1000x1000.jpg`);
|
||||||
if (imageResponse.ok) {
|
if (imageResponse.ok) {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const updateLatestSales = async (updatedCards: Set<number>) => {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const updatedCards = await syncPrices();
|
const updatedCards = await syncPrices();
|
||||||
await helper.upsertSkuCollection(db);
|
await helper.upsertSkuCollection(db);
|
||||||
|
await helper.upsertCardCollection(db);
|
||||||
//console.log(updatedCards);
|
//console.log(updatedCards);
|
||||||
//console.log(updatedCards.size);
|
//console.log(updatedCards.size);
|
||||||
//await updateLatestSales(updatedCards);
|
//await updateLatestSales(updatedCards);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const { title } = Astro.props;
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
|
<meta name="htmx-config" content='{"historyCacheSize": 50}'/>
|
||||||
|
<meta name="google-adsense-account" content="ca-pub-1140571217687341">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
|||||||
@@ -1,17 +1,45 @@
|
|||||||
// src/middleware.ts
|
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
|
||||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
|
|
||||||
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
|
|
||||||
|
|
||||||
const isProtectedRoute = createRouteMatcher([
|
const isProtectedRoute = createRouteMatcher(['/pokemon']);
|
||||||
'/pokemon',
|
const isAdminRoute = createRouteMatcher(['/admin']);
|
||||||
]);
|
|
||||||
|
|
||||||
export const onRequest = clerkMiddleware((auth, context) => {
|
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
|
||||||
const { isAuthenticated, redirectToSignIn } = auth()
|
|
||||||
|
export const onRequest = clerkMiddleware(async (auth, context) => {
|
||||||
|
const { isAuthenticated, userId, redirectToSignIn } = auth();
|
||||||
|
|
||||||
if (!isAuthenticated && isProtectedRoute(context.request)) {
|
if (!isAuthenticated && isProtectedRoute(context.request)) {
|
||||||
// Add custom logic to run before redirecting
|
return redirectToSignIn();
|
||||||
|
}
|
||||||
|
|
||||||
return redirectToSignIn()
|
if (isAdminRoute(context.request)) {
|
||||||
|
if (!isAuthenticated || !userId) {
|
||||||
|
return redirectToSignIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await clerkClient(context); // pass context here
|
||||||
|
const memberships = await client.organizations.getOrganizationMembershipList({
|
||||||
|
organizationId: TARGET_ORG_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Total memberships found:", memberships.data.length);
|
||||||
|
console.log("Current userId:", userId);
|
||||||
|
console.log("Memberships:", JSON.stringify(memberships.data.map(m => ({
|
||||||
|
userId: m.publicUserData?.userId,
|
||||||
|
role: m.role,
|
||||||
|
})), null, 2));
|
||||||
|
|
||||||
|
const userMembership = memberships.data.find(
|
||||||
|
(m) => m.publicUserData?.userId === userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userMembership || userMembership.role !== "org:admin") {
|
||||||
|
return context.redirect("/");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Clerk membership check failed:", e);
|
||||||
|
return context.redirect("/");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
18
src/pages/admin.astro
Normal file
18
src/pages/admin.astro
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
export const prerender = false;
|
||||||
|
import Layout from '../layouts/Main.astro';
|
||||||
|
import NavItems from '../components/NavItems.astro';
|
||||||
|
import NavBar from '../components/NavBar.astro';
|
||||||
|
import Footer from '../components/Footer.astro';
|
||||||
|
---
|
||||||
|
<Layout title="Admin Panel">
|
||||||
|
<NavBar slot="navbar">
|
||||||
|
<NavItems slot="navItems" />
|
||||||
|
</NavBar>
|
||||||
|
<div class="row mb-4" slot="page">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1>Admin Panel</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer slot="footer" />
|
||||||
|
</Layout>
|
||||||
@@ -88,13 +88,22 @@ for (const row of historyRows) {
|
|||||||
|
|
||||||
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
|
function computeVolatility(prices: number[]): { label: string; monthlyVol: number } {
|
||||||
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
|
if (prices.length < 2) return { label: '—', monthlyVol: 0 };
|
||||||
|
|
||||||
const returns: number[] = [];
|
const returns: number[] = [];
|
||||||
for (let i = 1; i < prices.length; i++) {
|
for (let i = 1; i < prices.length; i++) {
|
||||||
returns.push(Math.log(prices[i] / prices[i - 1]));
|
const ratio = prices[i] / prices[i - 1];
|
||||||
|
if (!isFinite(ratio) || ratio <= 0) continue; // skip bad ratios
|
||||||
|
returns.push(Math.log(ratio));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (returns.length < 2) return { label: '—', monthlyVol: 0 }; // ← key fix
|
||||||
|
|
||||||
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
|
||||||
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
|
const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / (returns.length - 1);
|
||||||
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
|
const monthlyVol = Math.sqrt(variance) * Math.sqrt(30);
|
||||||
|
|
||||||
|
if (!isFinite(monthlyVol)) return { label: '—', monthlyVol: 0 }; // safety net
|
||||||
|
|
||||||
const label = monthlyVol >= 0.30 ? 'High'
|
const label = monthlyVol >= 0.30 ? 'High'
|
||||||
: monthlyVol >= 0.15 ? 'Medium'
|
: monthlyVol >= 0.15 ? 'Medium'
|
||||||
: 'Low';
|
: 'Low';
|
||||||
@@ -310,7 +319,7 @@ const altSearchUrl = (card: any) => {
|
|||||||
|
|
||||||
<!-- Table only — chart is outside the tab panes -->
|
<!-- Table only — chart is outside the tab panes -->
|
||||||
<div class="w-100">
|
<div class="w-100">
|
||||||
<div class="alert alert-dark rounded p-2 mb-0 table-responsive">
|
<div class="alert alert-dark rounded p-2 mb-0 table-responsive d-none">
|
||||||
<h6>Latest Verified Sales</h6>
|
<h6>Latest Verified Sales</h6>
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<caption class="small">Filtered to remove mismatched language variants</caption>
|
<caption class="small">Filtered to remove mismatched language variants</caption>
|
||||||
|
|||||||
@@ -48,15 +48,53 @@ const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
|
|||||||
// synonyms alone (e.g. terms that need to match across multiple set names)
|
// synonyms alone (e.g. terms that need to match across multiple set names)
|
||||||
// and rewrites them into a direct filter, clearing the query so it doesn't
|
// and rewrites them into a direct filter, clearing the query so it doesn't
|
||||||
// also try to text-match against card names.
|
// also try to text-match against card names.
|
||||||
const EREADER_SETS = ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'];
|
|
||||||
const EREADER_RE = /^(e-?reader|e reader)$/i;
|
const ALIAS_FILTERS = [
|
||||||
|
// ── Era / set groupings ───────────────────────────────────────────────
|
||||||
|
{ re: /^(e-?reader|e reader)$/i, field: 'setName',
|
||||||
|
values: ['Expedition Base Set', 'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||||
|
|
||||||
|
{ re: /^neo$/i, field: 'setName',
|
||||||
|
values: ['Neo Genesis', 'Neo Discovery', 'Neo Revelation', 'Neo Destiny'] },
|
||||||
|
|
||||||
|
{ re: /^(wotc|wizards)$/i, field: 'setName',
|
||||||
|
values: ['Base Set', 'Jungle', 'Fossil', 'Base Set 2', 'Team Rocket',
|
||||||
|
'Gym Heroes', 'Gym Challenge', 'Neo Genesis', 'Neo Discovery',
|
||||||
|
'Neo Revelation', 'Neo Destiny', 'Expedition Base Set',
|
||||||
|
'Aquapolis', 'Skyridge', 'Battle-e'] },
|
||||||
|
|
||||||
|
{ re: /^(sun\s*(&|and)\s*moon|s(&|and)m|sm)$/i, field: 'setName',
|
||||||
|
values: ['Sun & Moon', 'Guardians Rising', 'Burning Shadows', 'Crimson Invasion',
|
||||||
|
'Ultra Prism', 'Forbidden Light', 'Celestial Storm', 'Dragon Majesty',
|
||||||
|
'Lost Thunder', 'Team Up', 'Unbroken Bonds', 'Unified Minds',
|
||||||
|
'Hidden Fates', 'Cosmic Eclipse', 'Detective Pikachu'] },
|
||||||
|
|
||||||
|
{ re: /^(sword\s*(&|and)\s*shield|s(&|and)s|swsh)$/i, field: 'setName',
|
||||||
|
values: ['Sword & Shield', 'Rebel Clash', 'Darkness Ablaze', 'Vivid Voltage',
|
||||||
|
'Battle Styles', 'Chilling Reign', 'Evolving Skies', 'Fusion Strike',
|
||||||
|
'Brilliant Stars', 'Astral Radiance', 'Pokemon GO', 'Lost Origin',
|
||||||
|
'Silver Tempest', 'Crown Zenith'] },
|
||||||
|
|
||||||
|
// ── Card type shorthands ──────────────────────────────────────────────
|
||||||
|
{ re: /^trainers?$/i, field: 'cardType', values: ['Trainer'] },
|
||||||
|
{ re: /^supporters?$/i, field: 'cardType', values: ['Supporter'] },
|
||||||
|
{ re: /^stadiums?$/i, field: 'cardType', values: ['Stadium'] },
|
||||||
|
{ re: /^items?$/i, field: 'cardType', values: ['Item'] },
|
||||||
|
{ re: /^(energys?|energies)$/i, field: 'cardType', values: ['Energy'] },
|
||||||
|
|
||||||
|
// ── Rarity shorthands ─────────────────────────────────────────────────
|
||||||
|
{ re: /^promos?$/i, field: 'rarityName', values: ['Promo'] },
|
||||||
|
];
|
||||||
|
|
||||||
let resolvedQuery = query;
|
let resolvedQuery = query;
|
||||||
let queryFilter = '';
|
let queryFilter = '';
|
||||||
|
|
||||||
if (EREADER_RE.test(query.trim())) {
|
for (const alias of ALIAS_FILTERS) {
|
||||||
resolvedQuery = '';
|
if (alias.re.test(query.trim())) {
|
||||||
queryFilter = `setName:=[${EREADER_SETS.map(s => '`' + s + '`').join(',')}]`;
|
resolvedQuery = '';
|
||||||
|
queryFilter = `${alias.field}:=[${alias.values.map(s => '`' + s + '`').join(',')}]`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = Array.from(formData.entries())
|
const filters = Array.from(formData.entries())
|
||||||
@@ -118,7 +156,10 @@ if (start === 0) {
|
|||||||
const searchRequests = { searches: searchArray };
|
const searchRequests = { searches: searchArray };
|
||||||
const commonSearchParams = {
|
const commonSearchParams = {
|
||||||
q: resolvedQuery,
|
q: resolvedQuery,
|
||||||
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
|
query_by: 'content,setName,setCode,productName',
|
||||||
|
query_by_weights: '10,6,8,9',
|
||||||
|
num_typos: '2,1,0,1',
|
||||||
|
prefix: 'true,true,false,false',
|
||||||
};
|
};
|
||||||
|
|
||||||
// use typesense to search for cards matching the query and return the productIds of the results
|
// use typesense to search for cards matching the query and return the productIds of the results
|
||||||
@@ -276,8 +317,8 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{pokemon.length === 0 && (
|
{pokemon.length === 0 && (
|
||||||
<div id="notfound" hx-swap-oob="true">
|
<div id="notfound" class="mt-4 h6" hx-swap-oob="true">
|
||||||
Pokemon not found
|
No cards found! Please modify your search and try again.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user