Compare commits
20 Commits
master
...
71c167308d
| Author | SHA1 | Date | |
|---|---|---|---|
| 71c167308d | |||
| cb829e1922 | |||
| 5dc7ce2de7 | |||
|
|
29ec850eef | ||
|
|
5d716a4d8e | ||
|
|
d06c6fb9bf | ||
|
|
404355304c | ||
|
|
87235ab37a | ||
| 12a42b87b8 | |||
| 03394d81e8 | |||
| 86da8a91ad | |||
| 66290fcb97 | |||
| 7a1b7eb5fe | |||
| 3be17fe84c | |||
| 38f041d86f | |||
|
|
b65e2a2859 | ||
|
|
91823174d2 | ||
|
|
943bd33c9a | ||
|
|
9975db20cb | ||
|
|
db12844dea |
58
CLAUDE.md
Normal file
58
CLAUDE.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Pokemon TCG card database and inventory management app. Users search cards, track market prices, and manage their collections. Closed beta.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (binds 0.0.0.0:4321)
|
||||
npm run build # Production build → ./dist/
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
No test framework or linter is configured.
|
||||
|
||||
Utility scripts in `scripts/` are run directly with `tsx` (e.g., `npx tsx scripts/reindex.ts`).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Astro 5** (SSR mode, Node.js standalone adapter)
|
||||
- **PostgreSQL** via Drizzle ORM (schema in `pokemon` namespace, snake_case DB columns)
|
||||
- **Typesense** for full-text card search
|
||||
- **Clerk** for authentication
|
||||
- **HTMX** for dynamic interactions (no SPA framework)
|
||||
- **Bootstrap 5** with custom SCSS overrides, dark theme
|
||||
- **Chart.js** for price history charts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Flow
|
||||
|
||||
TCGPlayer source data → `tcg_cards` → denormalized `cards` (per variant) → `skus` (per condition/language) → `price_history` / `sales_history`. User collections stored in `inventory` table linked to Clerk userId.
|
||||
|
||||
PostgreSQL is source of truth. Typesense mirrors card/sku/inventory data for search. Both must be kept in sync — see `src/pages/api/inventory.ts` for the sync pattern (write to PG, then upsert/delete in Typesense).
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `src/pages/` — Astro routes and API endpoints
|
||||
- `src/pages/partials/` — HTMX partial responses (HTML fragments returned to `hx-post` targets)
|
||||
- `src/pages/api/` — JSON/file API endpoints (`upload.ts` for CSV, `inventory.ts` for CRUD)
|
||||
- `src/components/` — Reusable `.astro` components
|
||||
- `src/db/` — Drizzle schema (`schema.ts`), relations (`relations.ts`), DB connection (`index.ts`), Typesense client (`typesense.ts`)
|
||||
- `scripts/` — Data ingestion and indexing utilities (not part of the app runtime)
|
||||
|
||||
### Authentication
|
||||
|
||||
Clerk middleware in `src/middleware.ts` protects routes via `createRouteMatcher`. Auth context accessed via `Astro.locals.auth()` in pages/API routes.
|
||||
|
||||
### Database Schema
|
||||
|
||||
Drizzle config uses `casing: 'snake_case'` — define schema fields in camelCase, they map to snake_case columns automatically. Migrations live in `./drizzle/`. Schema is scoped to the `pokemon` PostgreSQL schema.
|
||||
|
||||
### Frontend Patterns
|
||||
|
||||
Pages use HTMX for interactivity — forms POST to `/partials/*` endpoints that return HTML fragments. No client-side routing. View Transitions API enabled for page navigation animations. Card modals and inventory forms are HTMX-driven with `hx-post`, `hx-target`, and `hx-swap` attributes.
|
||||
@@ -18,5 +18,17 @@ export default defineConfig({
|
||||
output: "server",
|
||||
security: {
|
||||
checkOrigin: false
|
||||
}
|
||||
},
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// Silences deprecation warnings from dependencies
|
||||
quietDeps: true,
|
||||
// Specifically silence color function warnings
|
||||
silenceDeprecations: ['color-functions', 'import','global-builtin'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
110
package-lock.json
generated
110
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@clerk/astro": "^3.0.1",
|
||||
"@clerk/shared": "^4.0.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"astro": "^5.17.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
@@ -121,7 +122,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz",
|
||||
"integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@azure/core-auth": "^1.10.0",
|
||||
@@ -139,7 +139,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
@@ -152,7 +151,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz",
|
||||
"integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@azure/core-util": "^1.13.0",
|
||||
@@ -186,7 +184,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.2.tgz",
|
||||
"integrity": "sha512-Tf6ltdKzOJEgxZeWLCjMxrxbodB/ZeCbzzA1A2qHbhzAjzjHoBVSUeSl/baT/oHAxhc4qdqVaDKnc2+iE932gw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2"
|
||||
},
|
||||
@@ -203,7 +200,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz",
|
||||
"integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-util": "^1.2.0",
|
||||
@@ -219,7 +215,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
|
||||
"integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
@@ -251,7 +246,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
|
||||
"integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
@@ -264,7 +258,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz",
|
||||
"integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
"@typespec/ts-http-runtime": "^0.3.0",
|
||||
@@ -302,7 +295,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.0.0.tgz",
|
||||
"integrity": "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/abort-controller": "^2.0.0",
|
||||
"@azure/core-auth": "^1.3.0",
|
||||
@@ -322,7 +314,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.0.tgz",
|
||||
"integrity": "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure-rest/core-client": "^2.3.3",
|
||||
"@azure/abort-controller": "^2.1.2",
|
||||
@@ -346,7 +337,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
|
||||
"integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typespec/ts-http-runtime": "^0.3.0",
|
||||
"tslib": "^2.6.2"
|
||||
@@ -360,7 +350,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.29.0.tgz",
|
||||
"integrity": "sha512-/f3eHkSNUTl6DLQHm+bKecjBKcRQxbd/XLx8lvSYp8Nl/HRyPuIPOijt9Dt0sH50/SxOwQ62RnFCmFlGK+bR/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "15.15.0"
|
||||
},
|
||||
@@ -373,7 +362,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.15.0.tgz",
|
||||
"integrity": "sha512-/n+bN0AKlVa+AOcETkJSKj38+bvFs78BaP4rNtv3MJCmPH0YrHiskMRe74OhyZ5DZjGISlFyxqvf9/4QVEi2tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
@@ -383,7 +371,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.8.tgz",
|
||||
"integrity": "sha512-+f1VrJH1iI517t4zgmuhqORja0bL6LDQXfBqkjuMmfTYXTQQnh1EvwwxO3UbKLT05N0obF72SRHFrC1RBDv5Gg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "15.15.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -1474,8 +1461,7 @@
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz",
|
||||
"integrity": "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@js-temporal/polyfill": {
|
||||
"version": "0.5.1",
|
||||
@@ -1803,6 +1789,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
@@ -2248,8 +2235,7 @@
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
|
||||
"integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/bootstrap": {
|
||||
"version": "5.2.10",
|
||||
@@ -2326,6 +2312,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz",
|
||||
"integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -2336,6 +2323,7 @@
|
||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -2347,7 +2335,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
|
||||
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -2363,7 +2350,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz",
|
||||
"integrity": "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
@@ -2384,7 +2370,6 @@
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
@@ -2409,7 +2394,6 @@
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
@@ -2543,6 +2527,7 @@
|
||||
"resolved": "https://registry.npmjs.org/astro/-/astro-5.18.1.tgz",
|
||||
"integrity": "sha512-m4VWilWZ+Xt6NPoYzC4CgGZim/zQUO7WFL0RHCH0AiEavF1153iC3+me2atDvXpf/yX4PyGUeD8wZLq1cirT3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@astrojs/compiler": "^2.13.0",
|
||||
"@astrojs/internal-helpers": "0.7.6",
|
||||
@@ -2684,15 +2669,13 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -2766,7 +2749,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
@@ -2776,15 +2758,13 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
},
|
||||
@@ -3160,7 +3140,6 @@
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
|
||||
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
"default-browser-id": "^5.0.0"
|
||||
@@ -3177,7 +3156,6 @@
|
||||
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
|
||||
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -3190,7 +3168,6 @@
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4043,7 +4020,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
@@ -4214,7 +4190,6 @@
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -4230,7 +4205,6 @@
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
@@ -4749,7 +4723,6 @@
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
@@ -4763,7 +4736,6 @@
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
@@ -4777,7 +4749,6 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -4807,8 +4778,7 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
@@ -4946,8 +4916,7 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
|
||||
"integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
@@ -4973,7 +4942,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
@@ -4996,7 +4964,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
@@ -5008,7 +4975,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
@@ -5027,50 +4993,43 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
@@ -5982,7 +5941,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mssql/-/mssql-11.0.1.tgz",
|
||||
"integrity": "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tediousjs/connection-string": "^0.5.0",
|
||||
"commander": "^11.0.0",
|
||||
@@ -6003,7 +5961,6 @@
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
@@ -6016,7 +5973,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tedious/-/tedious-18.6.2.tgz",
|
||||
"integrity": "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/core-auth": "^1.7.2",
|
||||
"@azure/identity": "^4.2.1",
|
||||
@@ -6070,8 +6026,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
|
||||
"integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/neotraverse": {
|
||||
"version": "0.6.18",
|
||||
@@ -6186,7 +6141,6 @@
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
@@ -6284,6 +6238,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -6491,7 +6446,6 @@
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
@@ -6555,7 +6509,6 @@
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -6821,14 +6774,14 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -6873,7 +6826,6 @@
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -6899,21 +6851,20 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.98.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
|
||||
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
@@ -7099,8 +7050,7 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
@@ -7138,7 +7088,6 @@
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -7232,7 +7181,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
|
||||
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -7242,7 +7190,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tedious/-/tedious-19.2.1.tgz",
|
||||
"integrity": "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@azure/core-auth": "^1.7.2",
|
||||
"@azure/identity": "^4.2.1",
|
||||
@@ -7351,6 +7298,7 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -7382,6 +7330,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7722,7 +7671,6 @@
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
@@ -8375,7 +8323,6 @@
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
},
|
||||
@@ -8454,6 +8401,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@clerk/astro": "^3.0.1",
|
||||
"@clerk/shared": "^4.0.0",
|
||||
"@clerk/themes": "^2.4.55",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"astro": "^5.17.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chalk": "^5.6.2",
|
||||
|
||||
33
scripts/diagnose-join.ts
Normal file
33
scripts/diagnose-join.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'dotenv/config';
|
||||
import chalk from 'chalk';
|
||||
import util from 'node:util';
|
||||
import { client } from '../src/db/typesense.ts';
|
||||
|
||||
const variants = [
|
||||
'$skus(*, $cards(*))',
|
||||
'$skus(*,$cards(*))',
|
||||
'$skus(*, card_id, $cards(*))',
|
||||
'$skus(*, $cards(*, strategy:nest))',
|
||||
'$skus(*, $cards(*, strategy:merge))',
|
||||
];
|
||||
|
||||
const debug = await client.debug.retrieve();
|
||||
console.log(chalk.cyan(`Typesense server version: ${debug.version}`));
|
||||
console.log();
|
||||
|
||||
for (const include of variants) {
|
||||
console.log(chalk.yellow(`include_fields: ${include}`));
|
||||
try {
|
||||
const res: any = await client.collections('inventories').documents().search({
|
||||
q: '*',
|
||||
query_by: 'content',
|
||||
per_page: 1,
|
||||
include_fields: include,
|
||||
});
|
||||
const doc = res.hits?.[0]?.document;
|
||||
console.log(util.inspect(doc, { depth: null, colors: false }));
|
||||
} catch (err: any) {
|
||||
console.log(chalk.red(` ERROR: ${err.message ?? err}`));
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import type { DBInstance } from '../src/db/index.ts';
|
||||
import fs from "node:fs/promises";
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
import * as util from 'util';
|
||||
|
||||
|
||||
const DollarToInt = (dollar: any) => {
|
||||
if (dollar === null) return null;
|
||||
@@ -62,7 +64,7 @@ export const createCardCollection = async () => {
|
||||
{ name: 'releaseDate', type: 'int32' },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true, sort: true },
|
||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||
{ name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||
// { name: 'sku_id', type: 'string[]', optional: true, reference: 'skus.id', async_reference: true }
|
||||
],
|
||||
});
|
||||
console.log(chalk.green('Collection "cards" created successfully.'));
|
||||
@@ -83,11 +85,39 @@ export const createSkuCollection = async () => {
|
||||
{ name: 'highestPrice', type: 'int32', optional: true },
|
||||
{ name: 'lowestPrice', type: 'int32', optional: true },
|
||||
{ name: 'marketPrice', type: 'int32', optional: true },
|
||||
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "skus" created successfully.'));
|
||||
}
|
||||
|
||||
// Delete and recreate the 'inventory' index
|
||||
export const createInventoryCollection = async () => {
|
||||
try {
|
||||
await client.collections('inventories').delete();
|
||||
} catch (error) {
|
||||
// Ignore error, just means collection doesn't exist
|
||||
}
|
||||
await client.collections().create({
|
||||
name: 'inventories',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'userId', type: 'string' },
|
||||
{ name: 'catalogName', type: 'string' },
|
||||
{ name: 'card_id', type: 'string', reference: 'cards.id', async_reference: true },
|
||||
{ name: 'sku_id', type: 'string', reference: 'skus.id', async_reference: true },
|
||||
// content,setName,productLineName,rarityName,energyType,cardType from cards for searching
|
||||
{ name: 'content', type: 'string', token_separators: ['/'] },
|
||||
{ name: 'setName', type: 'string' },
|
||||
{ name: 'productLineName', type: 'string' },
|
||||
{ name: 'rarityName', type: 'string' },
|
||||
{ name: 'energyType', type: 'string' },
|
||||
{ name: 'cardType', type: 'string' },
|
||||
]
|
||||
});
|
||||
console.log(chalk.green('Collection "inventories" created successfully.'));
|
||||
}
|
||||
|
||||
|
||||
export const upsertCardCollection = async (db:DBInstance) => {
|
||||
const pokemon = await db.query.cards.findMany({
|
||||
@@ -113,7 +143,7 @@ export const upsertCardCollection = async (db:DBInstance) => {
|
||||
content: [card.productName, card.productLineName, card.set?.setName || "", card.number, card.rarityName, card.artist || ""].join(' '),
|
||||
releaseDate: card.tcgdata?.releaseDate ? Math.floor(new Date(card.tcgdata.releaseDate).getTime() / 1000) : 0,
|
||||
...(marketPrice !== null && { marketPrice }),
|
||||
sku_id: card.prices.map(price => price.skuId.toString())
|
||||
// sku_id: card.prices.map(price => price.skuId.toString())
|
||||
};
|
||||
}), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "cards" indexed successfully.'));
|
||||
@@ -127,10 +157,38 @@ export const upsertSkuCollection = async (db:DBInstance) => {
|
||||
highestPrice: DollarToInt(sku.highestPrice),
|
||||
lowestPrice: DollarToInt(sku.lowestPrice),
|
||||
marketPrice: DollarToInt(sku.marketPrice),
|
||||
card_id: sku.cardId.toString(),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "skus" indexed successfully.'));
|
||||
}
|
||||
|
||||
export const upsertInventoryCollection = async (db:DBInstance) => {
|
||||
const inv = await db.query.inventory.findMany({
|
||||
with: { sku: { with: { card: { with: { set: true } } } } }
|
||||
});
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
card_id: i.sku?.cardId.toString(),
|
||||
sku_id: i.skuId.toString(),
|
||||
productLineName: i.sku?.card?.productLineName,
|
||||
rarityName: i.sku?.card?.rarityName,
|
||||
setName: i.sku?.card?.set?.setName || "",
|
||||
cardType: i.sku?.card?.cardType || "",
|
||||
energyType: i.sku?.card?.energyType || "",
|
||||
content: [
|
||||
i.sku?.card?.productName,
|
||||
i.sku?.card?.productLineName,
|
||||
i.sku?.card?.set?.setName || "",
|
||||
i.sku?.card?.number,
|
||||
i.sku?.card?.rarityName,
|
||||
i.sku?.card?.artist || ""
|
||||
].join(' '),
|
||||
})), { action: 'upsert' });
|
||||
console.log(chalk.green('Collection "inventories" indexed successfully.'));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import { db, ClosePool } from '../src/db/index.ts';
|
||||
import * as Indexing from './pokemon-helper.ts';
|
||||
|
||||
|
||||
//await Indexing.createCardCollection();
|
||||
//await Indexing.createSkuCollection();
|
||||
await Indexing.upsertCardCollection(db);
|
||||
// await Indexing.createCardCollection();
|
||||
await Indexing.createSkuCollection();
|
||||
await Indexing.createInventoryCollection();
|
||||
|
||||
// await Indexing.upsertCardCollection(db);
|
||||
await Indexing.upsertSkuCollection(db);
|
||||
await Indexing.upsertInventoryCollection(db);
|
||||
await ClosePool();
|
||||
console.log(chalk.green('Pokémon reindex complete.'));
|
||||
|
||||
@@ -172,6 +172,16 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
.col:has(.image-grow:hover) .inventory-button {
|
||||
opacity: 0.20;
|
||||
transition: opacity 350ms ease;
|
||||
}
|
||||
|
||||
.inventory-button {
|
||||
// add transition to existing rule
|
||||
transition: opacity 350ms ease;
|
||||
}
|
||||
|
||||
.card-modal {
|
||||
background-color: rgba(1, 11, 18, 0.8);
|
||||
cursor: default;
|
||||
@@ -278,6 +288,40 @@ $tiers: (
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inventory form condition buttons ──────────────────────────────────────
|
||||
// Reuses $tiers map so colors stay in sync with nav tabs and price-row
|
||||
|
||||
$cond-text: (
|
||||
nm: rgba(156, 204, 102, 1),
|
||||
lp: rgba(211, 225, 86, 1),
|
||||
mp: rgba(255, 238, 87, 1),
|
||||
hp: rgba(255, 201, 41, 1),
|
||||
dmg: rgba(255, 167, 36, 1),
|
||||
);
|
||||
|
||||
@each $name, $color in $tiers {
|
||||
@if map-has-key($cond-text, $name) {
|
||||
.btn-check:checked + .btn-cond-#{$name} {
|
||||
background-color: $color;
|
||||
border-color: $color;
|
||||
color: rgba(0, 0, 0, 0.94);
|
||||
}
|
||||
|
||||
.btn-cond-#{$name} {
|
||||
border-color: rgba($color, 0.4);
|
||||
color: var(--bs-body-color);
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.btn-check:not(:checked) + .btn-cond-#{$name}:hover {
|
||||
background-color: rgba($color, 0.67);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------
|
||||
Misc UI
|
||||
-------------------------------------------------- */
|
||||
@@ -378,6 +422,30 @@ $tiers: (
|
||||
stroke: var(--bs-info-border-subtle);
|
||||
}
|
||||
|
||||
.delete-svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: var(--bs-danger);
|
||||
stroke: var(--bs-danger);
|
||||
}
|
||||
|
||||
.edit-svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: var(--bs-warning);
|
||||
stroke: var(--bs-warning);
|
||||
}
|
||||
|
||||
.btn:hover .delete-svg {
|
||||
fill: var(--bs-danger-border-subtle);
|
||||
stroke: var(--bs-danger-border-subtle);
|
||||
}
|
||||
|
||||
.btn:hover .edit-svg {
|
||||
fill: var(--bs-warning-border-subtle);
|
||||
stroke: var(--bs-warning-border-subtle);
|
||||
}
|
||||
|
||||
.shadow-filter {
|
||||
filter:
|
||||
drop-shadow(0 5px 5px rgba(0, 0, 0, 0.3))
|
||||
@@ -417,21 +485,20 @@ $tiers: (
|
||||
);
|
||||
}
|
||||
|
||||
.inventory-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: -2rem;
|
||||
margin-right: -0.25rem;
|
||||
border-radius: 0.33rem;
|
||||
.inventory-button, .btn-vendor {
|
||||
background-color: hsl(262, 47%, 55%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.inventory-label {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
.inventory-button {
|
||||
margin-bottom: -2.25rem;
|
||||
margin-right: -0.25rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.inventory-button:hover, .btn-vendor:hover {
|
||||
background-color: hsl(262, 39%, 40%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fs-7 {
|
||||
|
||||
@@ -1,52 +1,97 @@
|
||||
import * as bootstrap from 'bootstrap';
|
||||
window.bootstrap = bootstrap;
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||
|
||||
// trap browser back and close the modal if open
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
const loadingMsg = cardModal.innerHTML;
|
||||
// Push a new history state when the modal is shown
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
history.pushState({ modalOpen: true }, null, '#cardModal');
|
||||
});
|
||||
// Listen for the browser's back button (popstate event)
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (cardModal.classList.contains('show')) {
|
||||
const modalInstance = bootstrap.Modal.getInstance(cardModal);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Trigger a back navigation when the modal is closed via its native controls (X, backdrop click)
|
||||
cardModal.addEventListener('hide.bs.modal', () => {
|
||||
cardModal.innerHTML = loadingMsg;
|
||||
if (history.state && history.state.modalOpen) {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Initialize all Bootstrap modals
|
||||
document.querySelectorAll('.modal').forEach(modalEl => {
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
});
|
||||
|
||||
import { Tooltip } from "bootstrap";
|
||||
|
||||
// Initialize all tooltips globally
|
||||
const initTooltips = () => {
|
||||
// Initialize tooltips
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
if (!el._tooltipInstance) {
|
||||
el._tooltipInstance = new Tooltip(el, {
|
||||
container: 'body', // ensures tooltip is appended to body, important for modals
|
||||
});
|
||||
el._tooltipInstance = new bootstrap.Tooltip(el, { container: 'body' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Run on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTooltips);
|
||||
// ---------------- DASHBOARD LOGIC ----------------
|
||||
const toggleBtn = document.getElementById("toggleViewBtn");
|
||||
const gridView = document.getElementById("gridView");
|
||||
const tableView = document.getElementById("tableView");
|
||||
const searchInput = document.getElementById("inventorySearch");
|
||||
const tbody = document.getElementById("inventoryRows");
|
||||
|
||||
if(toggleBtn && gridView && tableView && tbody) {
|
||||
// TOGGLE GRID/TABLE
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
if(gridView.style.display !== "none") {
|
||||
gridView.style.display = "none";
|
||||
tableView.style.display = "block";
|
||||
toggleBtn.textContent = "Switch to Grid View";
|
||||
} else {
|
||||
initTooltips();
|
||||
gridView.style.display = "block";
|
||||
tableView.style.display = "none";
|
||||
toggleBtn.textContent = "Switch to Table View";
|
||||
}
|
||||
});
|
||||
|
||||
// SEARCH FILTER
|
||||
if(searchInput) {
|
||||
searchInput.addEventListener("input", e => {
|
||||
const term = e.target.value.toLowerCase();
|
||||
[...tbody.querySelectorAll("tr")].forEach(row => {
|
||||
row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Optional: observe DOM changes for dynamically added tooltips (e.g., modals loaded later)
|
||||
const observer = new MutationObserver(() => initTooltips());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
// SORTING
|
||||
document.querySelectorAll("th[data-key]").forEach(th => {
|
||||
let sortAsc = true;
|
||||
th.addEventListener("click", () => {
|
||||
const key = th.dataset.key;
|
||||
const indexMap = {name:0,set:1,condition:2,qty:3,price:4,market:5,gain:6};
|
||||
const idx = indexMap[key];
|
||||
const rows = [...tbody.querySelectorAll("tr")];
|
||||
|
||||
rows.sort((a,b) => {
|
||||
let aText = a.children[idx].textContent.replace(/\$|,/g,'').toLowerCase();
|
||||
let bText = b.children[idx].textContent.replace(/\$|,/g,'').toLowerCase();
|
||||
if(!isNaN(aText) && !isNaN(bText)) return sortAsc ? aText-bText : bText-aText;
|
||||
return sortAsc ? aText.localeCompare(bText) : bText.localeCompare(aText);
|
||||
});
|
||||
|
||||
sortAsc = !sortAsc;
|
||||
tbody.innerHTML="";
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
});
|
||||
});
|
||||
|
||||
// INLINE EDITING + GAIN/LOSS UPDATE
|
||||
tbody.addEventListener("input", e => {
|
||||
const row = e.target.closest("tr");
|
||||
if(!row) return;
|
||||
|
||||
const priceCell = row.querySelector(".editable-price");
|
||||
const qtyCell = row.querySelector(".editable-qty");
|
||||
const marketCell = row.children[5];
|
||||
const gainCell = row.querySelector(".gain");
|
||||
|
||||
if(e.target.classList.contains("editable-price")) {
|
||||
e.target.textContent = e.target.textContent.replace(/[^\d.]/g,"");
|
||||
}
|
||||
if(e.target.classList.contains("editable-qty")) {
|
||||
e.target.textContent = e.target.textContent.replace(/\D/g,"");
|
||||
}
|
||||
|
||||
const price = parseFloat(priceCell.textContent) || 0;
|
||||
const qty = parseInt(qtyCell.textContent) || 0;
|
||||
const market = parseFloat(marketCell.textContent) || 0;
|
||||
const gain = market - price;
|
||||
|
||||
gainCell.textContent = (gain>=0 ? "+" : "-") + Math.abs(gain);
|
||||
gainCell.className = gain>=0 ? "gain text-success" : "gain text-danger";
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -32,6 +32,12 @@ function setEmptyState(isEmpty) {
|
||||
canvasWrapper.classList.toggle('d-none', isEmpty);
|
||||
}
|
||||
|
||||
function setChartVisible(visible) {
|
||||
const modal = document.getElementById('cardModal');
|
||||
const chartWrapper = modal?.querySelector('#priceHistoryChart')?.closest('.alert');
|
||||
if (chartWrapper) chartWrapper.classList.toggle('d-none', !visible);
|
||||
}
|
||||
|
||||
function buildChartData(history, rangeKey) {
|
||||
const cutoff = RANGE_DAYS[rangeKey] === Infinity
|
||||
? new Date(0)
|
||||
@@ -39,20 +45,14 @@ function buildChartData(history, rangeKey) {
|
||||
|
||||
const filtered = history.filter(r => new Date(r.calculatedAt) >= cutoff);
|
||||
|
||||
// Always build the full date axis for the selected window, even if sparse.
|
||||
// Generate one label per day in the range so the x-axis reflects the
|
||||
// chosen period rather than collapsing to only the days that have data.
|
||||
const dataDateSet = new Set(filtered.map(r => r.calculatedAt));
|
||||
const allDates = [...dataDateSet].sort((a, b) => new Date(a) - new Date(b));
|
||||
|
||||
// If we have real data, expand the axis to span from cutoff → today so
|
||||
// empty stretches at the start/end of a range are visible.
|
||||
let axisLabels = allDates;
|
||||
if (allDates.length > 0 && RANGE_DAYS[rangeKey] !== Infinity) {
|
||||
const start = new Date(cutoff);
|
||||
const end = new Date();
|
||||
const expanded = [];
|
||||
// Step through every day in the window
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
expanded.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
@@ -101,17 +101,9 @@ function buildChartData(history, rangeKey) {
|
||||
function updateChart() {
|
||||
if (!chartInstance) return;
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Always push the new labels/datasets to the chart so the x-axis
|
||||
// reflects the selected time window — even when there's no data for
|
||||
// the active condition. Then toggle the empty state overlay on top.
|
||||
chartInstance.data.labels = labels;
|
||||
chartInstance.data.datasets = datasets;
|
||||
chartInstance.update('none');
|
||||
|
||||
// Show the empty state overlay if the active condition has no points
|
||||
// in this window, but leave the (empty) chart visible underneath so
|
||||
// the axis communicates the selected period.
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
}
|
||||
|
||||
@@ -135,7 +127,6 @@ function initPriceChart(canvas) {
|
||||
|
||||
const { labels, datasets, hasData, activeConditionHasData } = buildChartData(allHistory, activeRange);
|
||||
|
||||
// Render the chart regardless — show empty state overlay if needed
|
||||
setEmptyState(!hasData || !activeConditionHasData);
|
||||
|
||||
chartInstance = new Chart(canvas.getContext('2d'), {
|
||||
@@ -202,9 +193,16 @@ function initFromCanvas(canvas) {
|
||||
activeCondition = "Near Mint";
|
||||
activeRange = '1m';
|
||||
const modal = document.getElementById('cardModal');
|
||||
|
||||
modal?.querySelectorAll('.price-range-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.range === '1m');
|
||||
});
|
||||
|
||||
// Hide chart if the vendor tab is already active when the modal opens
|
||||
// (e.g. opened via the inventory button)
|
||||
const activeTab = modal?.querySelector('.nav-link.active')?.getAttribute('data-bs-target');
|
||||
setChartVisible(activeTab !== '#nav-vendor');
|
||||
|
||||
initPriceChart(canvas);
|
||||
}
|
||||
|
||||
@@ -225,6 +223,10 @@ function setup() {
|
||||
document.addEventListener('shown.bs.tab', (e) => {
|
||||
if (!modal.contains(e.target)) return;
|
||||
const target = e.target?.getAttribute('data-bs-target');
|
||||
|
||||
// Hide the chart when the vendor tab is active, show it for all others
|
||||
setChartVisible(target !== '#nav-vendor');
|
||||
|
||||
const conditionMap = {
|
||||
'#nav-nm': 'Near Mint',
|
||||
'#nav-lp': 'Lightly Played',
|
||||
|
||||
@@ -44,15 +44,147 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<!-- <script src="src/assets/js/holofoil-init.js" is:inline></script> -->
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
|
||||
// ── Price mode helpers ────────────────────────────────────────────────────
|
||||
// marketPriceByCondition is injected into the modal HTML via a data attribute
|
||||
// on #inventoryEntryList: data-market-prices='{"Near Mint":6.00,...}'
|
||||
// See card-modal.astro for where this is set.
|
||||
|
||||
function getMarketPrices(form) {
|
||||
const listEl = form.closest('.tab-pane')?.querySelector('#inventoryEntryList')
|
||||
?? document.getElementById('inventoryEntryList');
|
||||
try {
|
||||
return JSON.parse(listEl?.dataset.marketPrices || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function applyPriceModeUI(form, mode) {
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const pricePrefix = form.querySelector('#pricePrefix');
|
||||
const priceSuffix = form.querySelector('#priceSuffix');
|
||||
const priceHint = form.querySelector('#priceHint');
|
||||
if (!priceInput) return;
|
||||
|
||||
const isPct = mode === 'percent';
|
||||
pricePrefix?.classList.toggle('d-none', isPct);
|
||||
priceSuffix?.classList.toggle('d-none', !isPct);
|
||||
priceInput.step = isPct ? '1' : '0.01';
|
||||
priceInput.max = isPct ? '100' : '';
|
||||
priceInput.placeholder = isPct ? '0' : '0.00';
|
||||
priceInput.classList.toggle('rounded-end', !isPct);
|
||||
priceInput.classList.toggle('rounded-start', isPct);
|
||||
|
||||
if (priceHint && !isPct) priceHint.textContent = 'Enter the purchase price.';
|
||||
}
|
||||
|
||||
function updatePriceHint(form) {
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const priceHint = form.querySelector('#priceHint');
|
||||
if (!priceInput || !priceHint) return;
|
||||
|
||||
const mode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
||||
if (mode !== 'percent') { priceHint.textContent = 'Enter the purchase price.'; return; }
|
||||
|
||||
const condition = form.querySelector('input[name="condition"]:checked')?.value ?? 'Near Mint';
|
||||
const prices = getMarketPrices(form);
|
||||
const marketPrice = prices[condition] ?? 0;
|
||||
const pct = parseFloat(priceInput.value) || 0;
|
||||
const resolved = ((pct / 100) * marketPrice).toFixed(2);
|
||||
priceHint.textContent = marketPrice
|
||||
? `= $${resolved} (${pct}% of $${marketPrice.toFixed(2)} market)`
|
||||
: 'No market price available for this condition.';
|
||||
}
|
||||
|
||||
function resolveFormPrice(form) {
|
||||
// Returns a FormData ready to POST; % is converted to $ in-place.
|
||||
const data = new FormData(form);
|
||||
const mode = data.get('priceMode');
|
||||
if (mode === 'percent') {
|
||||
const condition = data.get('condition');
|
||||
const prices = getMarketPrices(form);
|
||||
const marketPrice = prices[condition] ?? 0;
|
||||
const pct = parseFloat(data.get('purchasePrice')) || 0;
|
||||
data.set('purchasePrice', ((pct / 100) * marketPrice).toFixed(2));
|
||||
}
|
||||
data.delete('priceMode'); // UI-only field
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Empty state helper ────────────────────────────────────────────────────
|
||||
function syncEmptyState(invList) {
|
||||
const emptyState = document.getElementById('inventoryEmptyState');
|
||||
if (!emptyState) return;
|
||||
const hasEntries = invList.querySelector('[data-inventory-id]') !== null;
|
||||
emptyState.classList.toggle('d-none', hasEntries);
|
||||
}
|
||||
|
||||
// ── Inventory form init (binding price-mode UI events) ───────────────────
|
||||
function initInventoryForms(root = document) {
|
||||
// Fetch inventory entries for this card
|
||||
const invList = root.querySelector('#inventoryEntryList') || document.getElementById('inventoryEntryList');
|
||||
if (invList && !invList.dataset.inventoryFetched) {
|
||||
invList.dataset.inventoryFetched = 'true';
|
||||
const cardId = invList.dataset.cardId;
|
||||
if (cardId) {
|
||||
const body = new FormData();
|
||||
body.append('cardId', cardId);
|
||||
fetch('/api/inventory', { method: 'POST', body })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
})
|
||||
.catch(() => { invList.innerHTML = '<span class="text-danger">Failed to load inventory</span>'; });
|
||||
}
|
||||
}
|
||||
|
||||
const forms = root.querySelectorAll('[data-inventory-form]');
|
||||
|
||||
forms.forEach((form) => {
|
||||
if (form.dataset.inventoryBound === 'true') return;
|
||||
form.dataset.inventoryBound = 'true';
|
||||
|
||||
const priceInput = form.querySelector('#purchasePrice');
|
||||
const modeInputs = form.querySelectorAll('input[name="priceMode"]');
|
||||
const condInputs = form.querySelectorAll('input[name="condition"]');
|
||||
|
||||
// Set initial UI state
|
||||
const checkedMode = form.querySelector('input[name="priceMode"]:checked')?.value ?? 'dollar';
|
||||
applyPriceModeUI(form, checkedMode);
|
||||
|
||||
// Mode toggle
|
||||
modeInputs.forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (priceInput) priceInput.value = ''; // clear stale value on mode switch
|
||||
applyPriceModeUI(form, input.value);
|
||||
updatePriceHint(form);
|
||||
});
|
||||
});
|
||||
|
||||
// Condition change updates the hint when in % mode
|
||||
condInputs.forEach((input) => {
|
||||
input.addEventListener('change', () => updatePriceHint(form));
|
||||
});
|
||||
|
||||
// Live hint as user types
|
||||
priceInput?.addEventListener('input', () => updatePriceHint(form));
|
||||
|
||||
// Reset — restore to $ mode
|
||||
form.addEventListener('reset', () => {
|
||||
setTimeout(() => {
|
||||
applyPriceModeUI(form, 'dollar');
|
||||
updatePriceHint(form);
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort dropdown ─────────────────────────────────────────────────────────
|
||||
document.addEventListener('click', (e) => {
|
||||
const sortBy = document.getElementById('sortBy');
|
||||
|
||||
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
@@ -118,7 +250,6 @@ import BackToTop from "./BackToTop.astro"
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
|
||||
// Load with crossOrigin so toBlob() stays untainted
|
||||
await new Promise((resolve) => {
|
||||
const clean = new Image();
|
||||
clean.crossOrigin = 'anonymous';
|
||||
@@ -172,6 +303,20 @@ import BackToTop from "./BackToTop.astro"
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ── Tab switching helper ──────────────────────────────────────────────────
|
||||
function switchToRequestedTab() {
|
||||
const tab = sessionStorage.getItem('openModalTab');
|
||||
if (!tab) return;
|
||||
sessionStorage.removeItem('openModalTab');
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const tabEl = document.querySelector(`#cardModal [data-bs-target="#${tab}"]`);
|
||||
if (tabEl) bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const cardIndex = [];
|
||||
let currentCardId = null;
|
||||
@@ -259,10 +404,17 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (modal._reconnectChartObserver) modal._reconnectChartObserver();
|
||||
|
||||
modal.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
||||
bootstrap.Tab.getInstance(el)?.dispose();
|
||||
});
|
||||
|
||||
modal.innerHTML = html;
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
initInventoryForms(modal);
|
||||
updateNavButtons(modal);
|
||||
initChartAfterSwap(modal);
|
||||
switchToRequestedTab();
|
||||
};
|
||||
|
||||
if (document.startViewTransition && direction) {
|
||||
@@ -347,8 +499,14 @@ import BackToTop from "./BackToTop.astro"
|
||||
|
||||
if (target._reconnectChartObserver) target._reconnectChartObserver();
|
||||
|
||||
target.querySelectorAll('[data-bs-toggle="tab"]').forEach(el => {
|
||||
bootstrap.Tab.getInstance(el)?.dispose();
|
||||
});
|
||||
|
||||
target.innerHTML = html;
|
||||
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
initInventoryForms(target);
|
||||
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) {
|
||||
@@ -365,6 +523,7 @@ import BackToTop from "./BackToTop.astro"
|
||||
await transition.finished;
|
||||
updateNavButtons(target);
|
||||
initChartAfterSwap(target);
|
||||
switchToRequestedTab();
|
||||
|
||||
} catch (err) {
|
||||
console.error('[card-modal] transition failed:', err);
|
||||
@@ -380,13 +539,114 @@ import BackToTop from "./BackToTop.astro"
|
||||
});
|
||||
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
|
||||
// ── Delegated submit handler for inventory form ──────────────────────────
|
||||
cardModal.addEventListener('submit', async (e) => {
|
||||
const form = e.target.closest('[data-inventory-form]');
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
|
||||
|
||||
const cardId = form.closest('[data-card-id]')?.dataset.cardId;
|
||||
if (!cardId) return;
|
||||
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Saving…'; }
|
||||
|
||||
// resolveFormPrice converts % → $ and strips priceMode before POSTing
|
||||
const body = resolveFormPrice(form);
|
||||
body.append('action', 'add');
|
||||
body.append('cardId', cardId);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||
const html = await res.text();
|
||||
const invList = document.getElementById('inventoryEntryList');
|
||||
if (invList) {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
}
|
||||
form.reset();
|
||||
form.classList.remove('was-validated');
|
||||
// reset fires our listener which restores $ mode UI
|
||||
} catch {
|
||||
// keep current inventory list state
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Save to inventory'; }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Delegated click handler for inventory entry buttons ─────────────────
|
||||
cardModal.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('[data-inv-action]');
|
||||
if (!btn) return;
|
||||
|
||||
const article = btn.closest('[data-inventory-id]');
|
||||
if (!article) return;
|
||||
|
||||
const action = btn.dataset.invAction;
|
||||
const inventoryId = article.dataset.inventoryId;
|
||||
const cardId = article.dataset.cardId;
|
||||
const qtyEl = article.querySelector('[data-inv-qty]');
|
||||
let qty = Number(qtyEl?.textContent) || 1;
|
||||
|
||||
if (action === 'increment') {
|
||||
qtyEl.textContent = ++qty;
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'decrement') {
|
||||
if (qty > 1) qtyEl.textContent = --qty;
|
||||
return;
|
||||
}
|
||||
|
||||
// update or remove — POST to API and reload inventory list
|
||||
btn.disabled = true;
|
||||
const body = new FormData();
|
||||
body.append('cardId', cardId);
|
||||
|
||||
if (action === 'update') {
|
||||
body.append('action', 'update');
|
||||
body.append('inventoryId', inventoryId);
|
||||
body.append('quantity', String(qty));
|
||||
body.append('purchasePrice', article.dataset.purchasePrice);
|
||||
body.append('note', article.dataset.note || '');
|
||||
} else if (action === 'remove') {
|
||||
body.append('action', 'remove');
|
||||
body.append('inventoryId', inventoryId);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/inventory', { method: 'POST', body });
|
||||
const html = await res.text();
|
||||
const invList = document.getElementById('inventoryEntryList');
|
||||
if (invList) {
|
||||
invList.innerHTML = html || '';
|
||||
syncEmptyState(invList);
|
||||
}
|
||||
} catch {
|
||||
// keep current state
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
initChartAfterSwap(cardModal);
|
||||
initInventoryForms(cardModal);
|
||||
switchToRequestedTab();
|
||||
});
|
||||
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
updateNavButtons(null);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initInventoryForms();
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
37
src/components/InventoryTable.astro
Normal file
37
src/components/InventoryTable.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
const mockInventory = [
|
||||
{ name: "Charizard", set: "Base Set", condition: "NM", qty: 2, price: 350, market: 400, gain: 50 },
|
||||
{ name: "Pikachu", set: "Shining Legends", condition: "LP", qty: 5, price: 15, market: 20, gain: 5 },
|
||||
];
|
||||
---
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Card</th>
|
||||
<th>Set</th>
|
||||
<th>Condition</th>
|
||||
<th>Qty</th>
|
||||
<th>Price</th>
|
||||
<th>Market</th>
|
||||
<th>Gain/Loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockInventory.map(card => (
|
||||
<tr>
|
||||
<td>{card.name}</td>
|
||||
<td>{card.set}</td>
|
||||
<td>{card.condition}</td>
|
||||
<td>{card.qty}</td>
|
||||
<td>${card.price}</td>
|
||||
<td>${card.market}</td>
|
||||
<td class={card.gain >= 0 ? "text-success" : "text-danger"}>
|
||||
{card.gain >= 0 ? "+" : "-"}${Math.abs(card.gain)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -21,6 +21,13 @@ export const relations = defineRelations(schema, (r) => ({
|
||||
}),
|
||||
history: r.many.priceHistory(),
|
||||
latestSales: r.many.salesHistory(),
|
||||
inventories: r.many.inventory(),
|
||||
},
|
||||
inventory: {
|
||||
sku: r.one.skus({
|
||||
from: r.inventory.skuId,
|
||||
to: r.skus.skuId,
|
||||
}),
|
||||
},
|
||||
cards: {
|
||||
prices: r.many.skus(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//import { mysqlTable, int, varchar, boolean, decimal, datetime, index } from "drizzle-orm/mysql-core"
|
||||
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
|
||||
import { integer, varchar, boolean, decimal, timestamp, index, pgSchema, uuid, primaryKey } from "drizzle-orm/pg-core";
|
||||
|
||||
export const pokeSchema = pgSchema("pokemon");
|
||||
|
||||
@@ -98,6 +98,7 @@ export const skus = pokeSchema.table('skus', {
|
||||
},
|
||||
(table) => [
|
||||
index('idx_product_id_condition').on(table.productId, table.variant, table.condition),
|
||||
index('idx_card_id_condition').on(table.cardId, table.condition),
|
||||
]);
|
||||
|
||||
export const priceHistory = pokeSchema.table('price_history', {
|
||||
@@ -124,6 +125,20 @@ export const salesHistory = pokeSchema.table('sales_history',{
|
||||
primaryKey({ name: 'pk_sales_history', columns: [table.skuId, table.orderDate] })
|
||||
]);
|
||||
|
||||
export const inventory = pokeSchema.table('inventory',{
|
||||
inventoryId: uuid().primaryKey().notNull().defaultRandom(),
|
||||
userId: varchar({ length: 100 }).notNull(),
|
||||
catalogName: varchar({ length: 100 }),
|
||||
skuId: integer().notNull(),
|
||||
quantity: integer(),
|
||||
purchasePrice: decimal({ precision: 10, scale: 2 }),
|
||||
note: varchar({ length:255 }),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_userid_skuId').on(table.userId, table.skuId)
|
||||
]);
|
||||
|
||||
export const processingSkus = pokeSchema.table('processing_skus', {
|
||||
skuId: integer().primaryKey(),
|
||||
});
|
||||
|
||||
191
src/pages/api/inventory.ts
Normal file
191
src/pages/api/inventory.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../db/index';
|
||||
import { inventory, priceHistory } from '../../db/schema';
|
||||
import { client } from '../../db/typesense';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const GainLoss = (purchasePrice: any, marketPrice: any) => {
|
||||
if (!purchasePrice || !marketPrice) return '<div class="fs-5 fw-semibold">N/A</div>';
|
||||
const pp = Number(purchasePrice);
|
||||
const mp = Number(marketPrice);
|
||||
if (pp === mp) return '<div class="fs-5 fw-semibold text-warning">-</div>';
|
||||
if (pp > mp) return `<div class="fs-5 fw-semibold text-critical">-$${(pp - mp).toFixed(2)}</div>`;
|
||||
return `<div class="fs-6 fw-semibold text-success">+$${(mp - pp).toFixed(2)}</div>`;
|
||||
}
|
||||
|
||||
const getInventory = async (userId: string, cardId: number) => {
|
||||
|
||||
const card = await db.query.cards.findFirst({
|
||||
where: { cardId: cardId, },
|
||||
with : { prices: {
|
||||
with: { inventories: { where: { userId: userId } }, }
|
||||
}, },
|
||||
});
|
||||
|
||||
const invHtml = card?.prices?.flatMap(price => price.inventories.map(inv => {
|
||||
const marketPrice = price.marketPrice;
|
||||
const marketPriceDisplay = marketPrice ? `$${marketPrice}` : '—';
|
||||
const purchasePriceDisplay = inv.purchasePrice ? `$${Number(inv.purchasePrice).toFixed(2)}` : '—';
|
||||
|
||||
return `
|
||||
<article class="border rounded-4 p-2 inventory-entry-card"
|
||||
data-inventory-id="${inv.inventoryId}"
|
||||
data-card-id="${price.cardId}"
|
||||
data-purchase-price="${inv.purchasePrice}"
|
||||
data-note="${(inv.note || '').replace(/"/g, '"')}">
|
||||
<div class="d-flex flex-column">
|
||||
<!-- Top row -->
|
||||
<div class="d-flex justify-content-between gap-3">
|
||||
<div class="min-w-0 flex-grow-1">
|
||||
<div class="fw-semibold fs-6 text-body mb-1">${price.condition}</div>
|
||||
</div>
|
||||
<div class="fs-7 text-secondary">Added: ${inv.createdAt ? new Date(inv.createdAt).toLocaleDateString() : '—'}</div>
|
||||
</div>
|
||||
<!-- Middle row -->
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<div class="small text-secondary">Purchase price</div>
|
||||
<div class="fs-6 fw-semibold">${purchasePriceDisplay}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-secondary">Market price</div>
|
||||
<div class="fs-6 text-success">${marketPriceDisplay}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-secondary">Gain / loss</div>
|
||||
${GainLoss(inv.purchasePrice, marketPrice)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom row -->
|
||||
<div class="d-flex justify-content-between align-items-center gap-3 flex-wrap mt-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="small text-secondary">Qty</span>
|
||||
<div class="btn-group" role="group" aria-label="Quantity controls">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-inv-action="decrement">−</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" tabindex="-1" data-inv-qty>${inv.quantity}</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-inv-action="increment">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-inv-action="update">Edit</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" data-inv-action="remove" onclick="if(!confirm('Are you sure you want to remove this card from your inventory?')) event.stopImmediatePropagation();">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
})) || [];
|
||||
|
||||
return new Response(
|
||||
invHtml.join(''),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const addToInventory = async (userId: string, cardId: number, skuId: number, purchasePrice: number, quantity: number, note: string, catalogName: string) => {
|
||||
// First add to database
|
||||
const inv = await db.insert(inventory).values({
|
||||
userId: userId,
|
||||
skuId: skuId,
|
||||
catalogName: catalogName,
|
||||
purchasePrice: purchasePrice.toFixed(2),
|
||||
quantity: quantity,
|
||||
note: note,
|
||||
}).returning();
|
||||
// Get card details from the database to add to Typesense
|
||||
const card = await db.query.cards.findFirst({
|
||||
where: { cardId: cardId },
|
||||
with: { set: true },
|
||||
});
|
||||
|
||||
try {
|
||||
// And then add to Typesense for searching
|
||||
await client.collections('inventories').documents().import(inv.map(i => ({
|
||||
id: i.inventoryId,
|
||||
userId: i.userId,
|
||||
catalogName: i.catalogName,
|
||||
sku_id: i.skuId.toString(),
|
||||
productLineName: card?.productLineName,
|
||||
rarityName: card?.rarityName,
|
||||
setName: card?.set?.setName || "",
|
||||
cardType: card?.cardType || "",
|
||||
energyType: card?.energyType || "",
|
||||
card_id: card?.cardId.toString() || "",
|
||||
content: [
|
||||
card?.productName,
|
||||
card?.productLineName,
|
||||
card?.set?.setName || "",
|
||||
card?.number,
|
||||
card?.rarityName,
|
||||
card?.artist || ""
|
||||
].join(' '),
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Error adding inventory to Typesense:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromInventory = async (inventoryId: string) => {
|
||||
await db.delete(inventory).where(eq(inventory.inventoryId, inventoryId));
|
||||
await client.collections('inventories').documents(inventoryId).delete();
|
||||
}
|
||||
|
||||
const updateInventory = async (inventoryId: string, quantity: number, purchasePrice: number, note: string) => {
|
||||
// Update the database
|
||||
await db.update(inventory).set({
|
||||
quantity: quantity,
|
||||
purchasePrice: purchasePrice.toFixed(2),
|
||||
note: note,
|
||||
}).where(eq(inventory.inventoryId, inventoryId));
|
||||
// No need to update Typesense since we don't search by quantity or price
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
// Access form data from the request body
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action');
|
||||
const cardId = Number(formData.get('cardId')) || 0;
|
||||
const { userId } = locals.auth();
|
||||
|
||||
switch (action) {
|
||||
|
||||
case 'add':
|
||||
const purchasePrice = Number(formData.get('purchasePrice')) || 0;
|
||||
const quantity = Number(formData.get('quantity')) || 1;
|
||||
const note = formData.get('note')?.toString() || '';
|
||||
const catalogName = formData.get('catalogName')?.toString() || 'Default';
|
||||
const condition = formData.get('condition')?.toString() || 'Near Mint';
|
||||
const skuId = await db.query.skus.findFirst({
|
||||
where: { cardId: cardId, condition: condition },
|
||||
columns: { skuId: true },
|
||||
}).then(sku => sku?.skuId);
|
||||
if (!skuId) {
|
||||
return new Response('SKU not found for card', { status: 404 });
|
||||
}
|
||||
await addToInventory(userId!, cardId, skuId, purchasePrice, quantity, note, catalogName);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
const inventoryId = formData.get('inventoryId')?.toString() || '';
|
||||
await removeFromInventory(inventoryId);
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
const invId = formData.get('inventoryId')?.toString() || '';
|
||||
const qty = Number(formData.get('quantity')) || 1;
|
||||
const price = Number(formData.get('purchasePrice')) || 0;
|
||||
const invNote = formData.get('note')?.toString() || '';
|
||||
await updateInventory(invId, qty, price, invNote);
|
||||
break;
|
||||
|
||||
default:
|
||||
// No action = list inventory for this card
|
||||
return getInventory(userId!, cardId);
|
||||
}
|
||||
|
||||
// Always return current inventory after a mutation
|
||||
return getInventory(userId!, cardId);
|
||||
};
|
||||
565
src/pages/dashboard.astro
Normal file
565
src/pages/dashboard.astro
Normal file
@@ -0,0 +1,565 @@
|
||||
---
|
||||
import Layout from "../layouts/Main.astro";
|
||||
import NavBar from "../components/NavBar.astro";
|
||||
import NavItems from "../components/NavItems.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import FirstEditionIcon from "../components/FirstEditionIcon.astro";
|
||||
|
||||
|
||||
|
||||
// const totalQty = inventory.reduce((s, c) => s + c.qty, 0);
|
||||
// const totalValue = inventory.reduce((s, c) => s + nmPrice(c) * c.qty, 0);
|
||||
// const totalGain = inventory.reduce((s, c) => s + gain(c) * c.qty, 0);
|
||||
const totalQty = 1234;
|
||||
const totalValue = 5678.90;
|
||||
const totalGain = 1234.56;
|
||||
---
|
||||
|
||||
<Layout title="Inventory Dashboard">
|
||||
<NavBar slot="navbar">
|
||||
<NavItems slot="navItems" />
|
||||
</NavBar>
|
||||
|
||||
<div class="row g-0" style="min-height: calc(100vh - 120px)" slot="page">
|
||||
<aside class="col-12 col-md-2 border-end border-secondary bg-dark p-3 d-flex flex-column gap-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 text-uppercase text-secondary fw-bold ls-wide" style="letter-spacing:.08em">Catalogs</h6>
|
||||
<button
|
||||
class="btn btn-sm btn-success fs-7"
|
||||
title="New catalog"
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#newCatalogModal"
|
||||
>+ New</button>
|
||||
</div>
|
||||
|
||||
<ul id="catalogList" class="list-group list-group-flush">
|
||||
<li
|
||||
class="list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center justify-content-between active"
|
||||
data-catalog="all"
|
||||
role="button"
|
||||
style="cursor:pointer"
|
||||
>
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
View all cards
|
||||
</span>
|
||||
<span class="badge rounded-pill text-bg-secondary small">{totalQty}</span>
|
||||
</li>
|
||||
|
||||
{["Case Cards", "Japanese Singles", "Bulk"].map((name) => (
|
||||
<li
|
||||
class="ms-2 list-group-item list-group-item-action bg-transparent text-light border-0 rounded px-2 py-2 d-flex align-items-center gap-2"
|
||||
data-catalog={name}
|
||||
role="button"
|
||||
style="cursor:pointer"
|
||||
>
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto pt-3 border-top border-secondary small text-secondary">
|
||||
<div class="d-flex justify-content-between mb-1"><span>Total Cards</span><span class="text-light fw-semibold">{totalQty}</span></div>
|
||||
<div class="d-flex justify-content-between mb-1"><span>Market Value</span><span class="text-success fw-semibold">${totalValue.toFixed(0)}</span></div>
|
||||
<div class="d-flex justify-content-between"><span>Profit/Loss</span><span class={`fw-semibold ${totalGain >= 0 ? "text-success" : "text-danger"}`}>{totalGain >= 0 ? "+" : ""}${Math.abs(totalGain).toFixed(0)}</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="col-12 col-md-10 p-4">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button id="btnGrid" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn active" title="Images view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zm8 0A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm-8 8A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm8 0A1.5 1.5 0 0 1 10.5 9h3A1.5 1.5 0 0 1 15 10.5v3A1.5 1.5 0 0 1 13.5 15h-3A1.5 1.5 0 0 1 9 13.5v-3z"/>
|
||||
</svg>
|
||||
<span class="small ms-1">Images</span>
|
||||
</button>
|
||||
<button id="btnTable" type="button" class="btn btn-sm btn-link text-secondary px-1 view-toggle-btn" title="List view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/>
|
||||
</svg>
|
||||
<span class="small ms-1">List</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vr opacity-25 mx-1"></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-vendor"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#addCardModal"
|
||||
>+ Add Card</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-light"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#bulkImportModal"
|
||||
>Bulk Import</button>
|
||||
|
||||
<div class="ms-auto position-relative">
|
||||
<input
|
||||
id="inventorySearch"
|
||||
class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||
placeholder="Search inventory…"
|
||||
style="min-width:200px; padding-right:2rem"
|
||||
/>
|
||||
<button
|
||||
id="clearSearch"
|
||||
type="button"
|
||||
class="btn btn-sm p-0 position-absolute top-50 end-0 translate-middle-y me-2 text-secondary d-none"
|
||||
style="line-height:1"
|
||||
aria-label="Clear search"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="inventoryView">
|
||||
<div id="gridView" class="row g-4 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5" hx-post="/partials/inventory-cards" hx-trigger="load">
|
||||
</div>
|
||||
|
||||
<!-- <div id="tableView" style="display:none">
|
||||
<div class="inv-list-wrap">
|
||||
<table class="table align-middle mb-0 inv-list-table">
|
||||
<tbody id="inventoryRows">
|
||||
{inventory.map(card => {
|
||||
const market = nmPrice(card);
|
||||
const purchase = nmPurchase(card);
|
||||
const diff = market - purchase;
|
||||
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
|
||||
const isGain = diff >= 0;
|
||||
|
||||
return (
|
||||
<tr class="inv-list-row">
|
||||
<td class="inv-list-cardcell">
|
||||
<div class="inv-list-card">
|
||||
<div
|
||||
class="inv-list-thumb card-trigger"
|
||||
data-card-id={card.productId}
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#cardModal"
|
||||
>
|
||||
<img
|
||||
src={`/cards/${card.productId}.jpg`}
|
||||
alt={card.productName}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror="this.onerror=null;this.src='/cards/default.jpg';"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="inv-list-info">
|
||||
<div
|
||||
class="inv-list-name"
|
||||
data-card-id={card.productId}
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#cardModal"
|
||||
style="cursor:pointer"
|
||||
>
|
||||
{card.productName}
|
||||
</div>
|
||||
|
||||
<div class="inv-list-meta">
|
||||
<div class="inv-list-setlink">{card.setName}</div>
|
||||
<div>{card.rarityName}</div>
|
||||
<div>{card.number}</div>
|
||||
</div>
|
||||
|
||||
<div class="inv-list-condition">
|
||||
<span>Near Mint</span>
|
||||
<span>•</span>
|
||||
<span>{card.variant !== "Normal" ? card.variant : "Holofoil"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inv-list-right">
|
||||
<div class={`inv-list-price-line ${isGain ? "up" : "down"}`}>
|
||||
<span class="inv-grid-arrow small">{isGain ? "▲" : "▼"}</span>
|
||||
<span class="inv-list-price">${market.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class={`inv-list-delta ${isGain ? "up" : "down"}`}>
|
||||
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)} ({isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%)
|
||||
</div>
|
||||
<div class="inv-list-qty">Qty: {card.qty}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-secondary small mt-2 ps-1" id="rowCount"></div>
|
||||
</div> -->
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- <div class="modal fade" id="newCatalogModal" tabindex="-1" aria-labelledby="newCatalogLabel" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title" id="newCatalogLabel">Create Catalog</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="catalogNameInput">Catalog Name</label>
|
||||
<input id="catalogNameInput" type="text" class="form-control bg-dark-subtle text-light border-secondary" placeholder="e.g. Japanese Holos" />
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" id="createCatalogBtn">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="bulkImportModal" tabindex="-1" aria-labelledby="bulkImportLabel" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title" id="bulkImportLabel">Bulk CSV Import</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-secondary mb-3">
|
||||
Upload a CSV exported from Collectr, TCGPlayer, or any marketplace. Columns: <code>name, set, condition, qty, price, market</code>.
|
||||
</p>
|
||||
<label class="form-label small text-secondary text-uppercase fw-semibold" for="csvFileInput">Choose File</label>
|
||||
<input id="csvFileInput" type="file" accept=".csv" class="form-control bg-dark-subtle text-light border-secondary" />
|
||||
<div id="csvPreview" class="mt-3 d-none">
|
||||
<p class="small text-secondary fw-semibold mb-1">Preview</p>
|
||||
<div class="border border-secondary rounded p-2 small text-secondary" id="csvPreviewContent">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" id="csvUploadBtn">Upload & Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="inventoryEditModal" tabindex="-1" aria-labelledby="inventoryEditLabel" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title" id="inventoryEditLabel">Edit Inventory</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="inventoryEditBody">
|
||||
<p class="text-secondary small">Select a card to edit its quantity and purchase price.</p>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="addCardModal" tabindex="-1" aria-labelledby="addCardLabel" aria-modal="true" role="dialog">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title" id="addCardLabel">Add Card</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" class="form-control bg-dark-subtle text-light border-secondary mb-3" placeholder="Search card name…" id="addCardSearch" />
|
||||
<p class="text-secondary small">Search results will appear here. Connect to your card database API to enable live search.</p>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" disabled>Add Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<Footer slot="footer" />
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
|
||||
.view-toggle-btn {
|
||||
text-decoration: none;
|
||||
color: var(--bs-secondary-color) !important;
|
||||
}
|
||||
|
||||
.view-toggle-btn.active {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.view-toggle-btn.active svg,
|
||||
.view-toggle-btn:hover svg {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
#catalogList .list-group-item.active {
|
||||
background-color: rgba(var(--bs-danger-rgb), .15) !important;
|
||||
color: rgba(var(--bs-danger-rgb), 1) !important;
|
||||
border-left: 2px solid var(--bs-danger) !important;
|
||||
}
|
||||
|
||||
#gridView {
|
||||
row-gap: 1.5rem;
|
||||
}
|
||||
|
||||
.inv-grid-card {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inv-grid-media {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inv-grid-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: .85rem .15rem 0;
|
||||
}
|
||||
|
||||
.inv-grid-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inv-grid-title {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.05;
|
||||
font-weight: 500;
|
||||
margin-bottom: .4rem;
|
||||
color: var(--bs-emphasis-color);
|
||||
}
|
||||
|
||||
.inv-grid-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .2rem;
|
||||
}
|
||||
|
||||
.inv-grid-submeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
font-size: .9rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.inv-grid-price {
|
||||
min-width: 112px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
color: #f3f3f3 !important;
|
||||
}
|
||||
|
||||
.inv-grid-trend,
|
||||
.inv-list-price-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: .35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.inv-grid-value,
|
||||
.inv-list-price {
|
||||
font-size: 1.15rem;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.inv-grid-trend.up .inv-grid-arrow,
|
||||
.inv-list-price-line.up .inv-grid-arrow {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.inv-grid-trend.down,
|
||||
.inv-list-price-line.down {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.inv-grid-delta,
|
||||
.inv-list-delta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inv-grid-delta.up,
|
||||
.inv-list-delta.up {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.inv-grid-delta.down,
|
||||
.inv-list-delta.down {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.inv-grid-qty,
|
||||
.inv-list-qty {
|
||||
margin-top: .35rem;
|
||||
font-size: .9rem;
|
||||
color: #1ea7a1;
|
||||
}
|
||||
|
||||
.inv-grid-cart,
|
||||
.inv-list-cart {
|
||||
position: absolute;
|
||||
right: .35rem;
|
||||
bottom: .1rem;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #4cb7b3;
|
||||
background: transparent;
|
||||
color: #38a9a5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: .15s ease;
|
||||
}
|
||||
|
||||
.inv-grid-cart:hover,
|
||||
.inv-list-cart:hover {
|
||||
background: rgba(76, 183, 179, .08);
|
||||
color: #2a9a96;
|
||||
}
|
||||
|
||||
#tableView {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inv-list-wrap {
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.inv-list-table {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-hover-bg: transparent;
|
||||
--bs-table-color: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inv-list-table tbody,
|
||||
.inv-list-table tr,
|
||||
.inv-list-table td {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.inv-list-row + .inv-list-row .inv-list-cardcell {
|
||||
border-top: 1px solid rgba(0, 0, 0, .08) !important;
|
||||
}
|
||||
|
||||
.inv-list-cardcell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.inv-list-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 116px;
|
||||
padding: .8rem 4.5rem .8rem .35rem;
|
||||
background: #f3f3f3;
|
||||
border: 1px solid rgba(0, 0, 0, .08);
|
||||
border-radius: .35rem;
|
||||
}
|
||||
|
||||
.inv-list-thumb {
|
||||
width: 70px;
|
||||
flex: 0 0 70px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inv-list-thumb img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.inv-list-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inv-list-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin-bottom: .35rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.inv-list-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.inv-list-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .1rem;
|
||||
font-size: .95rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.inv-list-setlink {
|
||||
color: #5b6f8f;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.inv-list-condition {
|
||||
margin-top: .35rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .35rem;
|
||||
font-size: .95rem;
|
||||
color: #2b7a78;
|
||||
}
|
||||
|
||||
.inv-list-right {
|
||||
margin-left: auto;
|
||||
min-width: 140px;
|
||||
text-align: right;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inv-grid-body {
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.inv-grid-price {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.inv-grid-trend {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.inv-list-card {
|
||||
align-items: flex-start;
|
||||
padding-right: 3.75rem;
|
||||
}
|
||||
|
||||
.inv-list-right {
|
||||
min-width: 0;
|
||||
padding-right: .25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,18 @@ import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
|
||||
import { Tooltip } from "bootstrap";
|
||||
|
||||
import { clerkClient } from '@clerk/astro/server';
|
||||
|
||||
const { userId, has } = Astro.locals.auth();
|
||||
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
|
||||
|
||||
let hasAccess = has({ feature: 'inventory_add' });
|
||||
|
||||
if (!hasAccess && userId) {
|
||||
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
|
||||
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
|
||||
}
|
||||
|
||||
export const partial = true;
|
||||
export const prerender = false;
|
||||
|
||||
@@ -161,6 +173,17 @@ const conditionAttributes = (price: any) => {
|
||||
}[condition];
|
||||
};
|
||||
|
||||
// ── Build a market price lookup keyed by condition for use in JS ──────────
|
||||
const marketPriceByCondition: Record<string, number> = {};
|
||||
for (const price of card?.prices ?? []) {
|
||||
if (price.condition && price.marketPrice != null) {
|
||||
marketPriceByCondition[price.condition] = Number(price.marketPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derive distinct variants available for this card ─────────────────────
|
||||
const availableVariants = [...new Set(cardSkus.map(s => s.variant))].sort();
|
||||
|
||||
const ebaySearchUrl = (card: any) => {
|
||||
return `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&LH_Sold=1&Graded=No&_dcat=183454`;
|
||||
};
|
||||
@@ -168,8 +191,8 @@ const ebaySearchUrl = (card: any) => {
|
||||
const altSearchUrl = (card: any) => {
|
||||
return `https://alt.xyz/browse?query=${encodeURIComponent(card?.productUrlName)}+${encodeURIComponent(card?.set?.setUrlName)}+${encodeURIComponent(card?.number)}&sortBy=newest_first`;
|
||||
};
|
||||
---
|
||||
|
||||
---
|
||||
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||
<div class="modal-content" data-card-id={card?.cardId}>
|
||||
<div class="modal-header border-0">
|
||||
@@ -226,34 +249,36 @@ const altSearchUrl = (card: any) => {
|
||||
<ul class="nav nav-tabs nav-fill border-0 me-3 mb-2" id="myTab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link nm active" id="nm-tab" data-bs-toggle="tab" data-bs-target="#nav-nm" type="button" role="tab" aria-controls="nav-nm" aria-selected="true">
|
||||
<span class="d-none d-xxl-inline">Near Mint</span><span class="d-xxl-none">NM</span>
|
||||
<span class="d-none">Near Mint</span><span class="d-inline">NM</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link lp" id="lp-tab" data-bs-toggle="tab" data-bs-target="#nav-lp" type="button" role="tab" aria-controls="nav-lp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Lightly Played</span><span class="d-xxl-none">LP</span>
|
||||
<span class="d-none">Lightly Played</span><span class="d-inline">LP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link mp" id="mp-tab" data-bs-toggle="tab" data-bs-target="#nav-mp" type="button" role="tab" aria-controls="nav-mp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Moderately Played</span><span class="d-xxl-none">MP</span>
|
||||
<span class="d-none">Moderately Played</span><span class="d-inline">MP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link hp" id="hp-tab" data-bs-toggle="tab" data-bs-target="#nav-hp" type="button" role="tab" aria-controls="nav-hp" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Heavily Played</span><span class="d-xxl-none">HP</span>
|
||||
<span class="d-none">Heavily Played</span><span class="d-inline">HP</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link dmg" id="dmg-tab" data-bs-toggle="tab" data-bs-target="#nav-dmg" type="button" role="tab" aria-controls="nav-dmg" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Damaged</span><span class="d-xxl-none">DMG</span>
|
||||
<span class="d-none">Damaged</span><span class="d-inline">DMG</span>
|
||||
</button>
|
||||
</li>
|
||||
{/* {hasAccess && ( */}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link vendor d-none" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||
<button class="nav-link vendor" id="vendor-tab" data-bs-toggle="tab" data-bs-target="#nav-vendor" type="button" role="tab" aria-controls="nav-vendor" aria-selected="false">
|
||||
<span class="d-none d-xxl-inline">Inventory</span><span class="d-xxl-none">+/-</span>
|
||||
</button>
|
||||
</li>
|
||||
{/* )} */}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="myTabContent">
|
||||
@@ -310,7 +335,7 @@ const altSearchUrl = (card: any) => {
|
||||
|
||||
<!-- Table only — chart is outside the tab panes -->
|
||||
<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>
|
||||
<table class="table table-sm mb-0">
|
||||
<caption class="small">Filtered to remove mismatched language variants</caption>
|
||||
@@ -336,12 +361,204 @@ const altSearchUrl = (card: any) => {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* {hasAccess && ( */}
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6">
|
||||
<h6 class="mt-1 mb-2">Add {card?.productName} to inventory</h6>
|
||||
|
||||
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0"></div>
|
||||
<form id="inventoryForm" data-inventory-form novalidate>
|
||||
<div class="row gx-3 gy-1">
|
||||
<div class="col-3">
|
||||
<label for="quantity" class="form-label">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control mt-1"
|
||||
id="quantity"
|
||||
name="quantity"
|
||||
min="1"
|
||||
step="1"
|
||||
value="1"
|
||||
required
|
||||
/>
|
||||
<div class="invalid-feedback">Required.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-9">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
|
||||
<label for="purchasePrice" class="form-label">
|
||||
Purchase price
|
||||
</label>
|
||||
|
||||
<div class="btn-group btn-group-sm price-toggle" role="group" aria-label="Price mode">
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="priceMode"
|
||||
id="mode-dollar"
|
||||
value="dollar"
|
||||
autocomplete="off"
|
||||
checked
|
||||
/>
|
||||
<label class="btn btn-outline-secondary" for="mode-dollar">$</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="priceMode"
|
||||
id="mode-percent"
|
||||
value="percent"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-outline-secondary" for="mode-percent">%</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<span class="input-group-text mt-1" id="pricePrefix">$</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control mt-1 rounded-end"
|
||||
id="purchasePrice"
|
||||
name="purchasePrice"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
aria-describedby="pricePrefix priceSuffix priceHint"
|
||||
required
|
||||
/>
|
||||
<span class="input-group-text d-none mt-1" id="priceSuffix">%</span>
|
||||
</div>
|
||||
|
||||
<div class="form-text" id="priceHint">Enter the purchase price.</div>
|
||||
<div class="invalid-feedback">Enter a purchase price.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Condition</label>
|
||||
<div class="btn-group condition-input w-100" role="group" aria-label="Condition">
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="condition"
|
||||
id="cond-nm"
|
||||
value="Near Mint"
|
||||
autocomplete="off"
|
||||
checked
|
||||
/>
|
||||
<label class="btn btn-cond-nm" for="cond-nm">NM</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="condition"
|
||||
id="cond-lp"
|
||||
value="Lightly Played"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-cond-lp" for="cond-lp">LP</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="condition"
|
||||
id="cond-mp"
|
||||
value="Moderately Played"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-cond-mp" for="cond-mp">MP</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="condition"
|
||||
id="cond-hp"
|
||||
value="Heavily Played"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-cond-hp" for="cond-hp">HP</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="condition"
|
||||
id="cond-dmg"
|
||||
value="Damaged"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="btn btn-cond-dmg" for="cond-dmg">DMG</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="variant" value={card?.variant} />
|
||||
|
||||
<div class="col-12">
|
||||
<label for="catalogName" class="form-label">
|
||||
Catalog
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="catalogName"
|
||||
name="catalogName"
|
||||
list="catalogSuggestions"
|
||||
placeholder="Default"
|
||||
autocomplete="off"
|
||||
maxlength="100"
|
||||
/>
|
||||
<datalist id="catalogSuggestions">
|
||||
|
||||
</datalist>
|
||||
<div class="form-text">
|
||||
Type a name or pick an existing catalog.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="note" class="form-label">
|
||||
Note
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="note"
|
||||
name="note"
|
||||
rows="2"
|
||||
maxlength="255"
|
||||
placeholder="e.g. bought at local shop, gift, graded copy…"
|
||||
></textarea>
|
||||
<div class="form-text text-end" id="noteCount">0 / 255</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-3 pt-2">
|
||||
<button type="reset" class="btn btn-outline-danger flex-fill">Reset</button>
|
||||
<button type="submit" class="btn btn-success flex-fill">Save to inventory</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<h6 class="mt-1 mb-2">Inventory entries for {card?.productName}</h6>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div class="alert alert-dark border-0 rounded-4 d-none" id="inventoryEmptyState">
|
||||
<div class="fw-medium mb-1">No inventory entries yet</div>
|
||||
<div class="text-secondary small">
|
||||
Once you add copies of this card, they'll show up here.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inventory list -->
|
||||
<div class="d-flex flex-column gap-3" id="inventoryEntryList" data-card-id={cardId} data-market-prices={JSON.stringify(marketPriceByCondition)}>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* )} */}
|
||||
</div>
|
||||
|
||||
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->
|
||||
<div class="d-block d-lg-flex gap-1 mt-1">
|
||||
<div class="d-block d-lg-flex gap-1 mt-1 price-chart-container">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-dark rounded p-2 mb-0">
|
||||
<h6>Market Price History</h6>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
---
|
||||
import { client } from '../../db/typesense';
|
||||
import { clerkClient } from '@clerk/astro/server';
|
||||
|
||||
const { userId, has } = Astro.locals.auth();
|
||||
const TARGET_ORG_ID = 'org_3Baav9czkRLLlC7g89oJWqRRulK';
|
||||
|
||||
let hasAccess = has({ feature: 'inventory_add' });
|
||||
|
||||
if (!hasAccess && userId) {
|
||||
const memberships = await clerkClient(Astro).users.getOrganizationMembershipList({ userId });
|
||||
hasAccess = memberships.data.some(m => m.organization.id === TARGET_ORG_ID);
|
||||
}
|
||||
|
||||
import RarityIcon from '../../components/RarityIcon.astro';
|
||||
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
export const prerender = false;
|
||||
@@ -90,7 +102,7 @@ const facetFilter = (facet:string) => {
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'cards',
|
||||
filter_by: `sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
filter_by: `$skus(id:*) && sealed:false${languageFilter}${queryFilter ? ` && ${queryFilter}` : ''}${filterBy ? ` && ${filterBy}` : ''}`,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
@@ -131,6 +143,7 @@ const totalHits = cardResults?.found;
|
||||
|
||||
// format price to 2 decimal places (or 0 if price >=100) and adds a $ sign, if the price is null it returns "–"
|
||||
const formatPrice = (condition:string, skus: any) => {
|
||||
if (typeof skus === 'undefined' || skus.length === 0) return '—';
|
||||
const sku:any = skus.find((price:any) => price.condition === condition);
|
||||
if (typeof sku === 'undefined' || typeof sku.marketPrice === 'undefined') return '—';
|
||||
|
||||
@@ -234,7 +247,6 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
}
|
||||
</div>
|
||||
<script define:vars={{ totalHits, filters, facets }} is:inline>
|
||||
|
||||
// Filter the facet values to make things like Set easier to find
|
||||
const facetfilters = document.querySelectorAll('.facet-filter');
|
||||
for (const facetfilter of facetfilters) {
|
||||
@@ -260,7 +272,8 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
document.getElementById('searchform').dispatchEvent(new Event('submit', {bubbles:true, cancelable:true}));
|
||||
}
|
||||
document.getElementById('clear-filters').addEventListener('click', (e) => clearAllFilters(e));
|
||||
document.getElementById('clear-all-filters').addEventListener('click', (e) => clearAllFilters(e));
|
||||
const clearAllBtn = document.getElementById('clear-all-filters');
|
||||
if (clearAllBtn) clearAllBtn.addEventListener('click', (e) => clearAllFilters(e));
|
||||
|
||||
// Remove single facet value
|
||||
for (const li of document.querySelectorAll('.remove-filter')) {
|
||||
@@ -283,9 +296,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
|
||||
|
||||
{pokemon.map((card:any) => (
|
||||
<div class="col">
|
||||
<div class="inventory-button position-relative float-end shadow-filter text-center d-none">
|
||||
<div class="inventory-label pt-2">+/-</div>
|
||||
</div>
|
||||
{/* {hasAccess && ( */}
|
||||
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="event.stopPropagation(); sessionStorage.setItem('openModalTab', 'nav-vendor');">
|
||||
<b>+/–</b>
|
||||
</button>
|
||||
{/* )} */}
|
||||
<div class="card-trigger position-relative" data-card-id={card.cardId} hx-get={`/partials/card-modal?cardId=${card.cardId}`} hx-target="#cardModal" hx-trigger="click" data-bs-toggle="modal" data-bs-target="#cardModal" onclick="const cardTitle = this.querySelector('#cardImage').getAttribute('alt'); dataLayer.push({'event': 'virtualPageview', 'pageUrl': this.getAttribute('hx-get'), 'pageTitle': cardTitle, 'previousUrl': '/pokemon'});">
|
||||
<div class="image-grow rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}><img src={`/static/cards/${card.productId}.jpg`} alt={card.productName} id="cardImage" loading="lazy" decoding="async" class="img-fluid rounded-4 mb-2 w-100" onerror="this.onerror=null; this.src='/static/cards/default.jpg'; this.closest('.image-grow')?.setAttribute('data-default','true')"/><span class="position-absolute top-50 start-0 d-inline medium-icon"><FirstEditionIcon edition={card?.variant} /></span>
|
||||
<div class="holo-shine"></div>
|
||||
|
||||
112
src/pages/partials/inventory-cards.astro
Normal file
112
src/pages/partials/inventory-cards.astro
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
import { client } from '../../db/typesense';
|
||||
import RarityIcon from '../../components/RarityIcon.astro';
|
||||
import FirstEditionIcon from "../../components/FirstEditionIcon.astro";
|
||||
export const prerender = false;
|
||||
|
||||
import * as util from 'util';
|
||||
|
||||
// get the query from post request using form data
|
||||
const formData = await Astro.request.formData();
|
||||
const query = formData.get('q')?.toString() || '';
|
||||
const start = Number(formData.get('start')?.toString() || '0');
|
||||
|
||||
const { userId } = Astro.locals.auth();
|
||||
|
||||
|
||||
// primary search values (for cards)
|
||||
let searchArray = [{
|
||||
collection: 'inventories',
|
||||
filter_by: `userId:=${userId} && $skus($cards(id:*))`,
|
||||
per_page: 20,
|
||||
facet_by: '',
|
||||
max_facet_values: 0,
|
||||
page: Math.floor(start / 20) + 1,
|
||||
include_fields: '$skus(*),$cards(*)',
|
||||
}];
|
||||
|
||||
// on first load (start === 0) we want to get the facets for the filters
|
||||
// if (start === 0) {
|
||||
// for (const facet of Object.keys(facetFields)) {
|
||||
// searchArray.push({
|
||||
// collection: 'cards',
|
||||
// filter_by: facetFilter(facet),
|
||||
// per_page: 0,
|
||||
// facet_by: facet,
|
||||
// max_facet_values: 500,
|
||||
// page: 1,
|
||||
// sort_by: '',
|
||||
// include_fields: '',
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
|
||||
const searchRequests = { searches: searchArray };
|
||||
const commonSearchParams = {
|
||||
q: query,
|
||||
query_by: 'content,setName,productLineName,rarityName,energyType,cardType'
|
||||
// query_by: 'userId',
|
||||
};
|
||||
|
||||
// use typesense to search for cards matching the query and return the productIds of the results
|
||||
const searchResults = await client.multiSearch.perform(searchRequests, commonSearchParams);
|
||||
const inventoryResults = searchResults.results[0] as any;
|
||||
console.log('inventoryResults', util.inspect(inventoryResults, { depth: null }));
|
||||
|
||||
|
||||
const pokemon = inventoryResults.hits?.map((hit: any) => hit.document) ?? [];
|
||||
const totalHits = inventoryResults?.found;
|
||||
|
||||
console.log(`totalHits: ${totalHits}`);
|
||||
---
|
||||
|
||||
{pokemon.map((inventory:any) => {
|
||||
const sku = inventory.skus;
|
||||
const card = inventory.cards;
|
||||
const market = sku.marketPrice/100 || 0;
|
||||
const purchase = inventory.purchasePrice/100 || 0;
|
||||
const diff = market - purchase;
|
||||
const pct = purchase > 0 ? (diff / purchase) * 100 : 0;
|
||||
const isGain = diff >= 0;
|
||||
|
||||
return (
|
||||
<div class="col">
|
||||
<article class="inv-grid-card">
|
||||
<div class="card-trigger position-relative inv-grid-media" data-card-id={card.productId} data-bs-toggle="modal" data-bs-target="#cardModal">
|
||||
<div class="rounded-4 card-image" data-energy={card.energyType} data-rarity={card.rarityName} data-variant={card.variant} data-name={card.productName}>
|
||||
<img src={`static/cards/${card.productId}.jpg`} alt={card.productName} loading="lazy" decoding="async" class="img-fluid rounded-4 w-100" onerror="this.onerror=null;this.src='static/cards/default.jpg';this.closest('.image-grow')?.setAttribute('data-default','true')" />
|
||||
<span class="position-absolute top-50 start-0 d-inline medium-icon" style="z-index:4">
|
||||
<FirstEditionIcon edition={card.variant} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row justify-content-between my-1 align-items-center">
|
||||
<input type="number" class="form-control text-center" style="max-width: 50%;" value="1" min="1" max="999" aria-label="Quantity input" aria-describedby="button-minus button-plus">
|
||||
<div class="" aria-label="Edit controls">
|
||||
<button type="button" class="btn btn-outline-warning btn-sm"><svg class="edit-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M100.4 417.2C104.5 402.6 112.2 389.3 123 378.5L304.2 197.3L338.1 163.4C354.7 180 389.4 214.7 442.1 267.4L476 301.3L442.1 335.2L260.9 516.4C250.2 527.1 236.8 534.9 222.2 539L94.4 574.6C86.1 576.9 77.1 574.6 71 568.4C64.9 562.2 62.6 553.3 64.9 545L100.4 417.2zM156 413.5C151.6 418.2 148.4 423.9 146.7 430.1L122.6 517L209.5 492.9C215.9 491.1 221.7 487.8 226.5 483.2L155.9 413.5zM510 267.4C493.4 250.8 458.7 216.1 406 163.4L372 129.5C398.5 103 413.4 88.1 416.9 84.6C430.4 71 448.8 63.4 468 63.4C487.2 63.4 505.6 71 519.1 84.6L554.8 120.3C568.4 133.9 576 152.3 576 171.4C576 190.5 568.4 209 554.8 222.5C551.3 226 536.4 240.9 509.9 267.4z"/></svg></button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"><svg class="delete-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M232.7 69.9L224 96L128 96C110.3 96 96 110.3 96 128C96 145.7 110.3 160 128 160L512 160C529.7 160 544 145.7 544 128C544 110.3 529.7 96 512 96L416 96L407.3 69.9C402.9 56.8 390.7 48 376.9 48L263.1 48C249.3 48 237.1 56.8 232.7 69.9zM512 208L128 208L149.1 531.1C150.7 556.4 171.7 576 197 576L443 576C468.3 576 489.3 556.4 490.9 531.1L512 208z"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-1">
|
||||
<div class="p small text-secondary">{card.setName}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-1">
|
||||
<div class="h5">{card.productName}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-1 justify-content-between align-items-baseline">
|
||||
<div class={`inv-grid-trend small ${isGain ? "up" : "down"}`}>
|
||||
<span class="inv-grid-arrow">{isGain ? "▲" : "▼"}</span>
|
||||
<span class="h6 my-0">${market.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class={`inv-grid-delta small ${isGain ? "up" : "down"}`}>
|
||||
{isGain ? "+" : "-"}${Math.abs(diff).toFixed(2)}</br>{isGain ? "+" : "-"}{Math.abs(pct).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user