3 Commits

23 changed files with 816 additions and 215 deletions

7
.env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
canAddInventory: boolean;
}
}

View File

@@ -32,6 +32,14 @@ $container-max-widths: (
:root {
--total: 11;
--radius: 40px;
--purple: hsl(262, 47%, 63%);
--purple-dark: hsl(262, 47%, 45%);
--purple-light: hsl(262, 80%, 82%);
--hero-bg: hsl(258, 30%, 10%);
--aqua: hsl(173, 55%, 74%);
--honeydew: hsl(149, 64%, 92%);
--sand: hsl(45, 41%, 84%);
--pink: hsl(354, 65%, 59%);
}
html {
@@ -66,10 +74,13 @@ html {
/* --------------------------------------------------
Layout
-------------------------------------------------- */
body {
min-height: 100dvh;
}
.wrapper {
display: flex;
flex-direction: column;
min-height: 100dvh !important;
}
.main {
@@ -108,13 +119,15 @@ html {
Navbar & Icons
-------------------------------------------------- */
.navbar {
background-color: var(--bs-danger) !important;
background-color: rgb(126, 87, 194) !important;
border-bottom: #1d1f21 solid 1px;
z-index: 1030;
}
.sticky-top {
position: sticky;
top: 0;
z-index: 1000;
z-index: 1030;
}
.nav-icon {
@@ -161,6 +174,10 @@ html {
}
}
.cl-modalBackdrop {
background-color: rgba(1, 11, 18, 0.8);
}
.image-grow {
transition: box-shadow 350ms ease, transform 350ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.24);
@@ -255,12 +272,12 @@ html {
}
$tiers: (
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),
vendor: hsl(262, 47%, 55%)
nm: hsla(88, 50%, 67%, 1),
lp: hsla(66, 70%, 68%, 1),
mp: hsla(54, 100%, 73%, 1),
hp: hsla(46, 100%, 65%, 1),
dmg: hsla(36, 100%, 65%, 1),
vendor: hsla(262, 47%, 63%, 1)
);
@each $name, $color in $tiers {
@@ -275,10 +292,6 @@ $tiers: (
&.active {
background-color: $color;
border-bottom-color: $color;
@if $name == vendor {
color: rgba(255, 255, 255, 0.87);
}
}
}
@@ -292,11 +305,11 @@ $tiers: (
// 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),
nm: hsla(88, 50%, 67%, 1),
lp: hsla(66, 70%, 68%, 1),
mp: hsla(54, 100%, 73%, 1),
hp: hsla(46, 100%, 65%, 1),
dmg: hsla(36, 100%, 65%, 1),
);
@each $name, $color in $tiers {
@@ -349,7 +362,7 @@ $cond-text: (
/* Masked Image */
.masked-image {
z-index: 1000;
z-index: 1;
opacity: 1;
filter: brightness(0);
}
@@ -378,6 +391,28 @@ $cond-text: (
}
}
.logo-svg > svg {
width: 10rem;
transition: width 0.3s ease;
animation: logo-shrink linear both;
animation-timeline: scroll();
animation-range: 0px 100px;
@media (max-width: 1024px) {
width: 20vw;
}
}
@keyframes logo-shrink {
to {
width: 5rem;
@media (max-width: 1024px) {
width: 10vw;
}
}
}
.rarity-icon-large svg,
.set-icon svg {
margin-bottom: -0.25rem;
@@ -385,7 +420,7 @@ $cond-text: (
.filter-icon svg,
.search-button {
width: 2rem;
width: 1.5rem;
fill: rgba(255,255,255,0.87);
stroke: rgba(255,255,255,0.87);
}
@@ -413,13 +448,13 @@ $cond-text: (
.top-icon svg {
width: 2rem;
height: 2rem;
fill: var(--bs-info-bg-subtle);
stroke: var(--bs-info-bg-subtle);
fill: var(--bs-light-bg-subtle);
stroke: var(--bs-light-bg-subtle);
}
#btn-back-to-top:hover .top-icon svg {
fill: var(--bs-info-border-subtle);
stroke: var(--bs-info-border-subtle);
fill: var(--bs-light-border-subtle);
stroke: var(--bs-light-border-subtle);
}
.delete-svg {
@@ -486,7 +521,7 @@ $cond-text: (
}
.inventory-button, .btn-vendor {
background-color: hsl(262, 47%, 55%);
background-color: hsl(262, 47%, 63%);
color: #fff;
}
@@ -497,7 +532,7 @@ $cond-text: (
}
.inventory-button:hover, .btn-vendor:hover {
background-color: hsl(262, 39%, 40%);
background-color: hsl(262, 47%, 55%);
color: #fff;
}
@@ -510,7 +545,7 @@ $cond-text: (
font-size: 0.69rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
background-color: hsl(88, 50%, 60%);
background-color: hsl(88, 50%, 67%);
border-radius: 0.33rem 0 0 0.33rem;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.35);
@@ -519,10 +554,10 @@ $cond-text: (
@media (min-width: 1200px) { font-size: 0.8rem; }
@media (min-width: 1600px) { font-size: 1rem; }
&:nth-of-type(2) { background-color: hsl(66, 70%, 61%); }
&:nth-of-type(3) { background-color: hsl(54, 100%, 67%); }
&:nth-of-type(4) { background-color: hsl(45, 100%, 58%); }
&:last-of-type { background-color: hsl(36, 100%, 57%); border-radius: 0.33rem; }
&:nth-of-type(2) { background-color: hsl(66, 70%, 68%); }
&:nth-of-type(3) { background-color: hsl(54, 100%, 73%); }
&:nth-of-type(4) { background-color: hsl(46, 100%, 65%); }
&:last-of-type { background-color: hsl(36, 100%, 65%); border-radius: 0.33rem; }
}
/* --------------------------------------------------
@@ -532,7 +567,13 @@ $cond-text: (
@media (max-width: 768px) {
.search-box,
.search-button {
min-height: 48px;
min-height: 32px;
}
}
.search-container {
@media (max-width: 768px) {
width: 100%;
}
}
@@ -543,13 +584,24 @@ $cond-text: (
line-height: 2rem;
}
.search-input {
color: rgba(255,255,255,.94);
border: 1px solid rgba(94, 53, 177, 1);
}
.form-control:focus {
border-color: rgb(179, 157, 219);
outline: 0;
box-shadow: 0 0 0 0.15rem rgb(179, 157, 219, .67);
}
/* Sticky Search Bar */
.search-bar {
position: fixed;
bottom: 0;
width: 100%;
height: 48px;
z-index: 1000;
z-index: 1030;
transform: rotate(180deg);
@media (min-width: 768px) {
@@ -566,6 +618,50 @@ $cond-text: (
overflow-y: auto;
}
.search-container {
@media (max-width: 768px) {
margin-top: 0.25rem;
width: 100%;
animation: search-inline-mobile linear both;
animation-timeline: scroll();
animation-range: 0px 100px;
}
}
@keyframes search-inline-mobile {
from {
width: 100%;
}
to {
margin-top: 0 !important;
width: auto;
flex: 1;
}
}
.navbar .container {
@media (max-width: 768px) {
animation: navbar-nowrap linear both;
animation-timeline: scroll();
animation-range: 0px 100px;
}
}
.nav-user-btn {
animation: hide-user-btn linear both;
animation-timeline: scroll();
animation-range: 0px 100px;
}
@keyframes hide-user-btn {
to {
opacity: 0;
width: 0;
overflow: hidden;
pointer-events: none;
}
}
/* --------------------------------------------------
Circles
-------------------------------------------------- */
@@ -725,8 +821,167 @@ $cond-text: (
to { opacity: 1; filter: brightness(1) saturate(1); transform: scale(1); }
}
/* ── Utilities ── */
$colors: purple-light, "aqua", "honeydew", "cyan", "pink", "sand";
@each $c in $colors {
.text-#{$c} {
color: var(--#{$c});
}
}
.btn-purple {
background-color: var(--purple);
border-color: var(--purple-dark);
color: #fff;
&:hover { background-color: var(--purple-dark); border-color: var(--purple-dark); color: #fff; }
}
.btn-purple-secondary {
background-color: transparent;
border-color: var(--purple);
color: var(--purple-light);
&:hover {
background-color: hsla(262, 47%, 63%, 0.15);
border-color: var(--purple-light);
color: var(--purple-light);
}
}
.text-gradient {
background: linear-gradient(
90deg,
var(--purple-light),
var(--aqua),
var(--honeydew),
var(--purple),
var(--pink),
);
background-size: 300% 300%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gradientShift 12s ease-in-out infinite;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.eyebrow {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.py-6 { padding-top: 5rem; padding-bottom: 5rem; }
.hover-white:hover { color: #fff !important; }
/* ── Hero ── */
.hero {
background-color: var(--hero-bg);
}
.hero-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 50% at 80% 50%, hsla(262, 70%, 40%, 0.25) 0%, transparent 70%),
radial-gradient(ellipse 40% 60% at 20% 80%, hsla(190, 70%, 30%, 0.15) 0%, transparent 70%);
pointer-events: none;
}
/* ── Hero card mockup ── */
.hero-cards-mockup {
position: relative;
height: 340px;
}
.mockup-card {
position: absolute;
width: 225px;
aspect-ratio: 23 / 32;
overflow: hidden; /* clips the img to the card shape */
border: 1px solid hsla(262, 50%, 50%, 0.3);
}
.mockup-card--1 { left: 20%; top: 2.5%; transform: rotate(-8deg); }
.mockup-card--2 { left: 40%; top: 7%; transform: rotate(2deg); z-index: 1; }
.mockup-card--3 { left: 60%; top: 0; transform: rotate(10deg); }
.price-chip {
position: absolute;
bottom: 2.5%;
padding: 0.35rem 0.75rem;
border-radius: 2rem;
font-size: 0.8rem;
z-index: 2;
}
.price-chip--nm { left: 30%; background: hsl(88, 50%, 67%); color: rgba(0,0,0,.87); }
.price-chip--lp { left: 64%; background: hsl(66, 70%, 68%); color: rgba(0,0,0,.87); }
/* ── Stats bar ── */
.stats-bar { background-color: hsla(262, 20%, 12%, 0.6); }
/* ── Feature cards ── */
.feature-card {
background-color: hsla(262, 20%, 12%, 0.6);
border: 1px solid hsla(262, 30%, 40%, 0.2);
transition: border-color 0.2s, transform 0.2s;
&:hover { border-color: hsla(262, 50%, 60%, 0.5); transform: translateY(-2px); }
}
.feature-icon {
color: var(--purple-light);
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
}
/* ── Premium section ── */
.premium-section {
background-color: hsla(258, 30%, 8%, 0.8);
overflow-x: clip;
}
.premium-item {
background-color: hsla(262, 20%, 14%, 0.8);
border: 1px solid hsla(262, 30%, 35%, 0.2);
transition: border-color 0.2s;
&:hover { border-color: hsla(262, 50%, 60%, 0.4); }
}
.badge-coming {
display: inline-block;
flex-shrink: 0;
padding: 0.2rem 0.55rem;
border-radius: 2rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: hsla(262, 60%, 60%, 0.2);
color: var(--purple-light);
border: 1px solid hsla(262, 50%, 60%, 0.3);
margin-top: 0.1rem;
}
/* ── Compare ── */
.compare-icon { font-size: 2.5rem; line-height: 1; }
/* ── CTA ── */
.cta-section {
background: linear-gradient(180deg, transparent, hsla(262, 30%, 10%, 0.8));
}
/* ── Logo size override for footer ── */
footer .logo-svg > svg { width: var(--logo-width, 8rem); }
.hero-wrapper {
isolation: isolate;
}
/* --------------------------------------------------
Input Fix (Safari)
Input Fix (Safari)
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;

View File

@@ -3,11 +3,11 @@ import Chart from 'chart.js/auto';
const CONDITIONS = ["Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"];
const CONDITION_COLORS = {
"Near Mint": { active: 'rgba(156, 204, 102, 1)', muted: 'rgba(156, 204, 102, 0.67)' },
"Lightly Played": { active: 'rgba(211, 225, 86, 1)', muted: 'rgba(211, 225, 86, 0.67)' },
"Moderately Played": { active: 'rgba(255, 238, 87, 1)', muted: 'rgba(255, 238, 87, 0.67)' },
"Heavily Played": { active: 'rgba(255, 201, 41, 1)', muted: 'rgba(255, 201, 41, 0.67)' },
"Damaged": { active: 'rgba(255, 167, 36, 1)', muted: 'rgba(255, 167, 36, 0.67)' },
"Near Mint": { active: 'hsla(88, 50%, 67%, 1)', muted: 'hsla(88, 50%, 67%, 0.67)' },
"Lightly Played": { active: 'hsla(66, 70%, 68%, 1)', muted: 'hsla(66, 70%, 68%, 0.67)' },
"Moderately Played": { active: 'hsla(54, 100%, 73%, 1)', muted: 'hsla(54, 100%, 73%, 0.67)' },
"Heavily Played": { active: 'hsla(46, 100%, 65%, 1)', muted: 'hsla(46, 100%, 65%, 0.67)' },
"Damaged": { active: 'hsla(36, 100%, 65%, 1)', muted: 'hsla(36, 100%, 65%, 0.67)' },
};
const RANGE_DAYS = { '1m': 30, '3m': 90, '6m': 180, '1y': 365, 'all': Infinity };

View File

@@ -3,7 +3,7 @@
---
<button
type="button"
class="btn btn-info p-2 rounded-circle"
class="btn btn-light p-2 rounded-squircle"
aria-label="Back to Top"
aria-hidden="true"
id="btn-back-to-top"

View File

@@ -1,6 +1,7 @@
---
import BackToTop from "./BackToTop.astro"
---
<div class="container-fluid container-sm mt-3">
<div class="row mb-4">
<div class="col-md-2">
<div class="h5 d-none">Inventory management placeholder</div>
@@ -43,6 +44,7 @@ import BackToTop from "./BackToTop.astro"
</button>
<BackToTop />
</div>
<script is:inline>
(function () {
@@ -646,6 +648,15 @@ import BackToTop from "./BackToTop.astro"
document.addEventListener('DOMContentLoaded', () => {
initInventoryForms();
const pending = sessionStorage.getItem('pendingSearch');
if (pending) {
sessionStorage.removeItem('pendingSearch');
const input = document.getElementById('searchInput');
if (input) input.value = pending;
// The form's hx-trigger="load" will fire automatically on page load,
// picking up the pre-populated input value — no manual trigger needed.
}
});
})();

View File

@@ -1,18 +1,33 @@
---
import logo from "/src/svg/logo/rat_light.svg?raw";
---
<footer class="bd-footer py-4 py-md-5 mt-0 bg-body-tertiary">
<div class="container py-4 py-md-5 px-4 px-md-3 text-body-secondary">
<div class="row justify-content-end">
<div class="col mb-3">
<a class="btn btn-outline-success rounded p-2 float-end" href="/contact">
Contact Us
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path opacity=".25" d="M112 176L404 176C411.9 206.7 431 233 456.6 250.2L320 353.9L112 196.1L112 176zM112 256.3L305.5 403.1L320 414.1L334.5 403.1L509.2 270.6C515.3 271.5 521.6 272 528 272L528 464L112 464L112 256.3z"/>
<path d="M528 64C572.2 64 608 99.8 608 144C608 188.2 572.2 224 528 224C483.8 224 448 188.2 448 144C448 99.8 483.8 64 528 64zM88 128L401 128C400.3 133.2 400 138.6 400 144C400 155 401.4 165.8 404 176L112 176L112 196.1L320 353.9L456.6 250.3C472.1 260.7 489.9 267.8 509.2 270.7L334.5 403.2L320 414.2L305.5 403.2L112 256.4L112 464.1L528 464.1L528 272.1C545 272.1 561.2 268.8 576 262.8L576 512.1L64 512.1L64 128.1L88 128.1z"/>
</svg>
</a>
</div>
<footer class="footer py-5 border-top border-subtle" role="contentinfo">
<div class="container">
<div class="row g-4 mb-4">
<div class="col-md-4">
<a href="/" class="d-inline-block mb-3" aria-label="RAT home">
<span set:html={logo} class="logo-svg d-flex" style="--logo-width: 8rem;"></span>
</a>
<p class="text-body-secondary small">Real. Accurate. Transparent. Pokémon card price tracker for collectors who want to buy, sell, and trade with confidence.</p>
</div>
<nav class="col-md-2 ms-md-auto" aria-label="Tools">
<h3 class="h6 fw-semibold text-body-emphasis mb-3">Tools</h3>
<ul class="list-unstyled small text-body-secondary">
<li class="mb-2"><a href="/pokemon" class="text-body-secondary text-decoration-none hover-white">Browse Cards</a></li>
<li class="mb-2"><span class="text-body-tertiary">Inventory/Collection Tracker <em>(soon)</em></span></li>
</ul>
</nav>
<nav class="col-md-2" aria-label="Company">
<h3 class="h6 fw-semibold text-body-emphasis mb-3">Company</h3>
<ul class="list-unstyled small text-body-secondary">
<li class="mb-2"><a href="https://www.route301cards.com/" class="text-body-secondary text-decoration-none hover-white">About</a></li>
<li class="mb-2"><a href="/privacy" class="text-body-secondary text-decoration-none hover-white">Terms and Privacy</a></li>
</ul>
</nav>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center pt-4 border-top border-subtle">
<p class="text-body-tertiary small mb-0">© {new Date().getFullYear()} RAT. Not affiliated with Nintendo, The Pokémon Company, or their affiliates.</p>
<p class="text-body-tertiary small mb-0">Pokémon and all related names are trademarks of Nintendo / Creatures Inc. / GAME FREAK inc.</p>
</div>
</div>
</footer>
</footer>

56
src/components/Hero.astro Normal file
View File

@@ -0,0 +1,56 @@
---
import { SignInButton, Show } from '@clerk/astro/components'
import type { sign } from 'node:crypto'
---
<!-- ═══════════════════════════════════════════
HERO
═══════════════════════════════════════════ -->
<div class="hero position-relative overflow-hidden">
<div class="hero-bg" aria-hidden="true"></div>
<div class="container py-5 py-md-6 position-relative">
<div class="row align-items-center g-5">
<div class="col-xl-6">
<p class="eyebrow text-purple-light mb-3">Pokémon Card Price Aggregator</p>
<h1 class="display-4 fw-bold lh-sm mb-4">
The home of</br>
<span class="text-gradient">Real. Accurate. Transparent.</span><br/>
pricing data.
</h1>
<p class="lead text-body-secondary mb-4 pe-lg-4">
Real-time prices across the Pokémon trading card game. See prices for all conditions at a glance — no spreadsheets, no guesswork.
</p>
<div class="d-flex flex-wrap gap-3">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-purple btn-lg px-4">
Get Started Free
</button>
</SignInButton>
</Show>
<Show when="signed-in">
<SignInButton asChild mode="modal">
<a href="/pokemon" class="btn btn-outline-light btn-lg px-4">Browse Cards</a>
</SignInButton>
</Show>
</div>
<p class="mt-3 text-body-tertiary small d-none">Free forever. No credit card required.</p>
</div>
<div class="col-xl-6 d-none d-xl-block" aria-hidden="true">
<div class="hero-cards-mockup">
<div class="mockup-card mockup-card--1 shadow-lg rounded-4">
<img class="img-fluid" src="/static/cards/124125.jpg" alt="Imakuni?'s Doduo - XY - Evolutions (EVO)" />
</div>
<div class="mockup-card mockup-card--2 shadow-lg rounded-4">
<img class="img-fluid" src="/static/cards/88875.jpg" alt="Sabrina's Gengar - Gym Challenge (G2)" />
</div>
<div class="mockup-card mockup-card--3 shadow-lg rounded-4">
<img class="img-fluid" src="/static/cards/567429.jpg" alt="Squirtle - SV07: Stellar Crown (SCR)" />
</div>
<div class="price-chip price-chip--nm">NM <strong>$114.99</strong></div>
<div class="price-chip price-chip--lp">LP <strong>$85.66</strong></div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,12 +1,36 @@
---
import { UserButton, SignInButton, Show } from '@clerk/astro/components'
import logo from "/src/svg/logo/rat_light.svg?raw";
---
<nav class="navbar navbar-expand sticky-top bg-dark" data-bs-theme="dark" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand d-flex" href="/">
<span class="h3 d-none d-md-flex">Rigid's App Thing</span><span aria-hidden="true" class="h3 d-md-none d-flex m-auto">RAT</span>
<nav class="navbar sticky-top bg-dark shadow" data-bs-theme="dark" aria-label="Main navigation">
<div class="container align-items-center" id="navContainer">
<a class="navbar-brand" href="/">
<span set:html={logo} class="logo-svg d-flex"></span>
</a>
<slot name="navItems"/>
<slot name="searchInput"/>
<div class="d-flex d-md-none nav-user-btn" id="navUserBtn">
<Show when="signed-in">
<UserButton afterSignOutUrl="/" showName={false} />
</Show>
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-light">Sign In</button>
</SignInButton>
</Show>
<slot name="navItems"/>
</div>
<div class="d-flex flex-column-reverse flex-md-row search-container" id="searchContainer">
<slot name="searchInput"/>
<div class="d-none d-md-flex ms-4 nav-user-btn">
<Show when="signed-in">
<UserButton afterSignOutUrl="/" showName={false} />
</Show>
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-light">Sign In</button>
</SignInButton>
</Show>
<slot name="navItems"/>
</div>
</div>
</div>
</nav>

View File

@@ -1,16 +1,46 @@
---
---
<button
class="navbar-toggler ms-4 p-1 btn btn-purple border-0"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#navOffcanvas"
aria-controls="navOffcanvas"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
---
<div class="navbar-collapse" id="navbarNav" aria-labelledby="navbarToggler">
<ul class="navbar-nav ms-auto">
<li class="nav-item d-flex">
<a class="nav-link btn btn-warning rounded p-2" href="/pokemon" aria-label="Cards">
<span class="d-inline-block d-md-none" aria-hidden="true">Cards</span>
<svg aria-hidden="true" class="nav-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path opacity=".4" d="M256 519.9L256 576L576 576L576 128L378.8 128C408.7 239.7 438.6 351.3 468.5 463C397.7 482 326.8 501 256 519.9z"/>
<path d="M43.5 113L352.6 30.2L468.6 462.9L159.5 545.7z"/>
</svg>
</a>
</li>
</ul>
<div
id="navOffcanvasWrapper"
data-bs-theme="dark"
>
<div
class="offcanvas offcanvas-end"
tabindex="-1"
id="navOffcanvas"
aria-labelledby="navOffcanvasLabel"
>
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="navOffcanvasLabel">Menu</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body px-3 pt-0">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link py-3 border-bottom border-secondary" href="/pokemon">Browse Cards</a>
</li>
<li class="nav-item">
<a class="nav-link py-3" href="/dashboard">Dashboard</a>
</li>
</ul>
</div>
</div>
</div>
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const wrapper = document.getElementById('navOffcanvasWrapper');
if (wrapper) document.body.appendChild(wrapper);
});
</script>

View File

@@ -8,7 +8,6 @@ import { Show } from '@clerk/astro/components'
const val = Number(start.value) || 0;
start.value = (val + 20).toString();
}
// delete the triggering element
if (e && e.detail && e.detail.elt) {
e.detail.elt.remove();
}
@@ -26,21 +25,47 @@ import { Show } from '@clerk/astro/components'
</script>
<Show when="signed-in">
<form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
<a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
</span>
<span class="d-none d-md-block">Filters</span>
</a>
<form
class="d-flex align-items-center"
role="search"
id="searchform"
hx-post="/partials/cards"
hx-target="#cardGrid"
hx-trigger="load, submit"
hx-vals='{"start":"0"}'
hx-on--after-request="afterUpdate()"
hx-on--before-request="beforeSearch()"
>
<div class="input-group">
{Astro.url.pathname === '/pokemon' && (
<button class="btn btn-purple" data-bs-toggle="offcanvas" href="#filterBar" type="button" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528.8 96.3C558.6 90.8 571.2 118.9 568.9 142.2C572.3 173.4 570.8 207 553.9 230.8C513.9 283.2 459.3 315.9 414.3 364.3C414.9 418.3 419.8 459.8 423.6 511.2C427.6 552.4 388.7 586.8 346.6 570.1C303.2 550.5 259.4 527.5 230.4 493.3C217 453.1 225.9 407.5 222.2 365.3C222.2 365.3 222.1 365.1 222 365C151.4 319.6 59.3 250.9 61 158.4C59.9 121 91.8 96.1 123.8 96.5C259.3 98.5 394.1 104.4 528.8 96.3zM506.1 161.4C378.3 168.2 252 162.1 125.2 160.5C128.6 227 199 270.8 250 306.8C305.5 335.4 281.6 410.5 288.3 461.7C310.8 478.9 334.6 494.6 358.9 505.8C355.4 458 350.7 415.4 350.2 364.6C349.9 349.2 355.3 333.7 366.5 321.7C384.3 302.6 402.8 287.8 421.5 270.1C446.1 245.2 477.9 225.1 499.7 196.7C509 182.2 504.7 174.5 506 161.5z"/></svg>
</span>
<span class="d-none d-md-block fw-medium">Filters</span>
</button>
)}
<input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
<input type="search" name="q" id="searchInput" class="form-control search-input" placeholder="Search cards" />
<button
type="submit"
class="btn btn-purple border-start-0"
aria-label="search"
onclick="
const q = this.closest('form').querySelector('[name=q]').value;
dataLayer.push({ event: 'view_search_results', search_term: q });
if (window.location.pathname !== '/pokemon') {
event.preventDefault();
event.stopPropagation();
sessionStorage.setItem('pendingSearch', q);
window.location.href = '/pokemon';
}
"
>
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M503.7 304.9C520.3 80.3 214-44 100.9 169.4C-14.1 383.9 203.9 614.6 419.8 466.3C459.7 500.3 494.8 542.3 531.5 578.2C561.1 607.7 606.3 562.8 576.8 533L540 496.1C520.2 471.6 495.7 449.1 473.7 428.9C471.1 426.5 468.5 424.2 466 421.9C491.9 385.4 500.1 341 503.7 304.8zM236.1 129C334 92.1 452.1 198.1 440 298.6C440.5 404.9 335.6 462.2 244 445.8C99 407.1 100.3 178.9 236.2 129z"/></svg>
</button>
</div>
</form>
</Show>

View File

@@ -1,5 +1,8 @@
---
import '/src/assets/css/main.scss';
import NavBar from '../components/NavBar.astro';
import NavItems from '../components/NavItems.astro';
import Search from '../components/Search.astro';
const { title } = Astro.props;
---
@@ -25,18 +28,18 @@ const { title } = Astro.props;
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PPQMZ4PL"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<slot name="navbar"/>
<NavBar slot="navbar">
<NavItems slot="navItems" />
<Search slot="searchInput" />
</NavBar>
<div class="wrapper">
<div class="main">
<div class="container-fluid container-sm mt-4">
<slot name="page"/>
</div>
</div>
<div class="footer">
<slot name="footer"/>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="../assets/js/main.js"></script>

View File

@@ -1,17 +1,64 @@
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/astro/server';
import type { AstroMiddlewareRequest, AstroMiddlewareResponse } from 'astro';
import { clerkMiddleware, createRouteMatcher, clerkClient } from '@clerk/astro/server';
const isProtectedRoute = createRouteMatcher([
'/pokemon',
]);
const isProtectedRoute = createRouteMatcher(['/pokemon']);
const isAdminRoute = createRouteMatcher(['/admin']);
export const onRequest = clerkMiddleware((auth, context) => {
const { isAuthenticated, redirectToSignIn } = auth()
const TARGET_ORG_ID = "org_3Baav9czkRLLlC7g89oJWqRRulK";
export const onRequest = clerkMiddleware(async (auth, context) => {
const { isAuthenticated, userId, redirectToSignIn, has } = auth();
if (!isAuthenticated && isProtectedRoute(context.request)) {
// Add custom logic to run before redirecting
return redirectToSignIn();
}
return redirectToSignIn()
// ── Inventory visibility check ──────────────────────────────────────────────
// Resolves to true if the user belongs to the target org OR has the feature
const canAddInventory =
isAuthenticated &&
userId &&
(
has({ permission: "org:feature:inventory_add" }) || // Clerk feature flag
(await getUserOrgIds(context, userId)).includes(TARGET_ORG_ID)
);
// Expose the flag to your Astro pages via locals
context.locals.canAddInventory = canAddInventory ?? false;
// ── Admin route guard (unchanged) ───────────────────────────────────────────
if (isAdminRoute(context.request)) {
if (!isAuthenticated || !userId) {
return redirectToSignIn();
}
try {
const client = await clerkClient(context);
const memberships = await client.organizations.getOrganizationMembershipList({
organizationId: TARGET_ORG_ID,
});
const userMembership = memberships.data.find(
(m) => m.publicUserData?.userId === userId
);
if (!userMembership || userMembership.role !== "org:admin") {
return new Response(null, { status: 404 });
}
} catch (e) {
console.error("Clerk membership check failed:", e);
return context.redirect("/");
}
}
});
// ── Helper: fetch all org IDs the current user belongs to ───────────────────
async function getUserOrgIds(context: any, userId: string): Promise<string[]> {
try {
const client = await clerkClient(context);
const memberships = await client.users.getOrganizationMembershipList({ userId });
return memberships.data.map((m) => m.organization.id);
} catch (e) {
console.error("Failed to fetch user org memberships:", e);
return [];
}
}

View File

@@ -1,7 +1,7 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import Search from '../components/Search.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
import pokedexList from '../data/pokedex.json';
@@ -19,12 +19,10 @@ const pokemon = pokedexList.find(p => p["#"] === randomNumber);
const pokemonName = pokemon?.Name || "Unknown Pokémon";
---
<Layout title="404 - Page Not Found">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="container-fluid container-sm mt-4" slot="page">
<div class="row mb-4">
<div class="col-12 col-md-6">
<h1 class="mb-4">404<br/>Page Not Found</h1>
<h1 class="mb-4">404 - Page Not Found</h1>
<h4>Sorry, the page you are looking for does not exist.</h4>
<p class="copy-big my-4">
Return to the <a href="/">home page</a> or search for another <a href="/pokemon">Pokémon</a>.
@@ -67,7 +65,7 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
>???</h3>
<button
id="play-again"
class="btn btn-primary mt-3 opacity-0 pokemon-transition"
class="btn btn-purple mt-3 opacity-0 pokemon-transition"
style="pointer-events: none;"
aria-hidden="true"
>
@@ -76,6 +74,7 @@ const pokemonName = pokemon?.Name || "Unknown Pokémon";
</div>
</div>
</div>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -5,11 +5,11 @@ 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>';
if (!purchasePrice || !marketPrice) return '<div class="fs-6 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>`;
if (pp === mp) return '<div class="fs-6 fw-semibold text-warning">-</div>';
if (pp > mp) return `<div class="fs-6 fw-semibold text-danger">-$${(pp - mp).toFixed(2)}</div>`;
return `<div class="fs-6 fw-semibold text-success">+$${(mp - pp).toFixed(2)}</div>`;
}
const DollarToInt = (dollar: any) => {
@@ -71,7 +71,7 @@ const getInventory = async (userId: string, cardId: number) => {
</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-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>

View File

@@ -1,14 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
---
<Layout title="Contact Us">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12">
<h1>Contact Us</h1>

View File

@@ -1,7 +1,5 @@
---
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";
import { db } from '../db/index';
@@ -30,9 +28,6 @@ const totalGain = summary.totalGain || 0;
---
<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">

View File

@@ -1,49 +1,187 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import NavItems from '../components/NavItems.astro';
import Search from '../components/Search.astro';
import NavBar from '../components/NavBar.astro';
import Footer from '../components/Footer.astro';
import { Show, SignInButton, SignUpButton, SignOutButton } from '@clerk/astro/components'
import { Show, SignInButton } from '@clerk/astro/components'
import Hero from '../components/Hero.astro';
import BackToTop from '../components/BackToTop.astro';
import NavItems from '../components/NavItems.astro';
---
<Layout title="Rigid's App Thing">
<NavBar slot="navbar">
<NavItems slot="navItems" />
</NavBar>
<div class="row mb-4" slot="page">
<div class="col-12">
<h1>Rigid's App Thing</h1>
<p class="text-secondary">(working title)</p>
<Layout title="RAT - Realtime, Accurate and Transparent TCG Pricing Data" >
<Hero slot="page" />
<div slot="page">
<!-- ═══════════════════════════════════════════
SOCIAL PROOF / STATS BAR
═══════════════════════════════════════════ -->
<section class="stats-bar py-4 border-top border-bottom border-subtle" aria-label="Platform statistics">
<div class="container">
<ul class="list-unstyled d-flex flex-wrap justify-content-center justify-content-md-between gap-4 mb-0 text-center">
<li>
<strong class="d-block fs-4 fw-bold text-aqua">Pokémon TCG</strong>
<span class="text-body-secondary small">EN · JP Languages</span>
</li>
<li>
<strong class="d-block fs-4 fw-bold text-aqua">All Conditions</strong>
<span class="text-body-secondary small">NM · LP · MP · HP · DMG</span>
</li>
<li>
<strong class="d-block fs-4 fw-bold text-aqua">Real-Time</strong>
<span class="text-body-secondary small">Accurate Market Prices</span>
</li>
<li>
<strong class="d-block fs-4 fw-bold text-aqua">100% Free</strong>
<span class="text-body-secondary small">Pricing Features Always Free</span>
</li>
</ul>
</div>
<div class="col-12 col-md-6 mb-2">
<h2 class="mt-3">Welcome!</h2>
<p class="mt-2">
You've been selected to participate in the closed beta! This single page web application is meant to elevate condition/variant data for the Pokemon TCG. In future iterations, we will add vendor inventory/collection management features, as well.
</p>
<p class="my-2">
After the closed beta is complete, the app will move into a more open beta. Feel free to play "Who's that Pokémon?" with the random Pokémon generator <a href="/404">here</a>. Refresh the page to see a new Pokémon!
</p>
<Show when="signed-in">
<a href="/pokemon" class="btn btn-warning mt-2">Take me to the cards</a>
</Show>
</section>
<!-- ═══════════════════════════════════════════
CORE FEATURES
═══════════════════════════════════════════ -->
<section class="py-6" aria-labelledby="features-heading">
<div class="container">
<header class="text-center mb-5">
<h2 id="features-heading" class="h1 fw-bold">Everything you need to collect smarter</h2>
<p class="text-body-secondary lead mt-2">Built by collectors, for collectors. No fluff.</p>
</header>
<div class="row g-4">
<article class="col-md-5 offset-md-1">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"><path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">Complete Card Database</h3>
<p class="text-body-secondary mb-0">Search across every English and Japanese set. Find any card instantly with the condition-by-condition pricing you need to buy, sell, or trade with confidence.</p>
</div>
</article>
<article class="col-md-5">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"><path d="M0 0h1v15h15v1H0zm14.817 3.113a.5.5 0 0 1 .07.704l-4.5 5.5a.5.5 0 0 1-.74.037L7.06 6.767l-3.656 5.027a.5.5 0 0 1-.808-.588l4-5.5a.5.5 0 0 1 .758-.06l2.609 2.61 4.15-5.073a.5.5 0 0 1 .704-.07"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">Condition-Graded Pricing</h3>
<p class="text-body-secondary mb-0">NM, LP, MP, HP, and DMG prices displayed side by side. Stop guessing what a played card is worth — see every tier at once so you never undersell or overpay.</p>
</div>
</article>
<article class="col-md-5 offset-md-1">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">All Variants</h3>
<p class="text-body-secondary mb-0">We display every card variant separately—no stacking—so you can see true edition-level prices, trends, and rarity at a glance..</p>
</div>
</article>
<article class="col-md-5">
<div class="feature-card h-100 p-4 rounded-3">
<div class="feature-icon mb-3" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"><path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0"/></svg>
</div>
<h3 class="h5 fw-semibold mb-2">Fast Search, Instant Results</h3>
<p class="text-body-secondary mb-0">Type a card name + number or search by eras like "e-reader" or "SWSH". Powerful filters let you drill into exactly the set, variant, rarity or card type you care about.</p>
</div>
</article>
</div>
<div class="col-12 col-md-6 d-flex justify-content-end align-items-start mt-3">
<div class="d-flex gap-3">
</section>
<!-- ═══════════════════════════════════════════
UPCOMING PREMIUM / CTA TEASER
═══════════════════════════════════════════ -->
<section class="premium-section py-6" aria-labelledby="premium-heading">
<div class="container">
<div class="row align-items-center g-5">
<div class="col-lg-5">
<p class="eyebrow text-purple-light mb-3">Coming Soon · Premium</p>
<h2 id="premium-heading" class="h1 fw-bold mb-3">
<span class="text-gradient">Your collection,<br/>fully managed.</span>
</h2>
<p class="text-body-secondary lead mb-4">
We're building a suite of inventory and curation tools for serious collectors. Sign up free today and be first in line when they launch.
</p>
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-success">Sign In</button>
</SignInButton>
<SignUpButton asChild mode="modal">
<button class="btn btn-dark">Request Access</button>
</SignUpButton>
</Show>
<Show when="signed-in">
<SignOutButton asChild>
<button class="btn btn-danger">Sign Out</button>
</SignOutButton>
<SignInButton asChild mode="modal">
<button class="btn btn-purple btn-lg px-4">
Join Now! — It's Free
</button>
</SignInButton>
</Show>
</div>
<div class="col-lg-7">
<div class="premium-list">
<div class="premium-item p-4 rounded-3 mb-3">
<div class="d-flex align-items-start gap-3">
<span class="badge-coming">Coming Soon</span>
<div>
<h3 class="h6 fw-semibold mb-1">Collection Portfolio Tracker</h3>
<p class="text-body-secondary small mb-0">Add cards you own with their condition and purchase price. Watch your total collection value update in real time as market prices shift — so you always know your net position.</p>
</div>
</div>
</div>
<div class="premium-item p-4 rounded-3 mb-3">
<div class="d-flex align-items-start gap-3">
<span class="badge-coming">Coming Soon</span>
<div>
<h3 class="h6 fw-semibold mb-1">Latest Sales Aggregation</h3>
<p class="text-body-secondary small mb-0">See recent sale prices across different trusted marketplaces, including TCGPlayer and eBay. Make informed purchases based on real-time market activity, not just Market Price.</p>
</div>
</div>
</div>
<div class="premium-item p-4 rounded-3">
<div class="d-flex align-items-start gap-3">
<span class="badge-coming">Coming Soon</span>
<div>
<h3 class="h6 fw-semibold mb-1">Graded Card Inventory</h3>
<p class="text-body-secondary small mb-0">Log PSA, BGS, and CGC slabs with their cert numbers, grades, and current values. Track your graded portfolio separately from your raw collection.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════════════════════════════════════
FINAL CTA
═══════════════════════════════════════════ -->
<section class="cta-section py-6" aria-labelledby="cta-heading">
<div class="hero-bg" aria-hidden="true"></div>
<div class="container text-center">
<h2 id="cta-heading" class="display-5 fw-bold mb-3">Ready to join the RAT Pack?</h2>
<p class="lead text-body-secondary mb-4 mx-auto" style="max-width: 520px;">
Join free today. Browse every card, track prices across conditions, and get early access to premium collection tools as we build them.
</p>
<div class="d-flex flex-wrap justify-content-center gap-3">
<Show when="signed-out">
<SignInButton asChild mode="modal">
<button class="btn btn-purple btn-lg px-5">
Create Free Account
</button>
</SignInButton>
</Show>
<a href="/pokemon" class="btn btn-outline-light btn-lg px-5">Browse Cards First</a>
</div>
</div>
</section>
<BackToTop />
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -10,17 +10,8 @@ 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);
}
// auth check for inventory management features
const { canAddInventory } = Astro.locals;
export const partial = true;
export const prerender = false;
@@ -239,8 +230,8 @@ const altSearchUrl = (card: any) => {
<span class="rarity-icon-large position-absolute bottom-0 end-0 d-inline"><RarityIcon rarity={card?.rarityName} /></span>
</div>
<div class="d-flex flex-column flex-lg-row justify-content-between mt-2">
<div class="text-secondary">{card?.set?.setCode}</div>
<div class="text-secondary">Illus<span class="d-none d-lg-inline">trator</span>: {card?.artist}</div>
<div class="text-secondary"><span class="d-flex d-xxl-none">{card?.set?.setCode}</span><span class="d-none d-xxl-flex">{card?.set?.setName}</span></div>
<div class="text-secondary">Illus<span class="d-none d-xxl-inline">trator</span>: {card?.artist}</div>
</div>
</div>
@@ -272,13 +263,13 @@ const altSearchUrl = (card: any) => {
<span class="d-none">Damaged</span><span class="d-inline">DMG</span>
</button>
</li>
{/* {hasAccess && ( */}
{canAddInventory && (
<li class="nav-item" role="presentation">
<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">
@@ -289,7 +280,7 @@ const altSearchUrl = (card: any) => {
<div class="d-flex flex-column gap-1">
<!-- Stat cards -->
<div class="d-flex flex-fill flex-row gap-1">
<div class="d-flex flex-fill flex-row gap-1 flex-wrap flex-lg-nowrap">
<div class="alert alert-dark rounded p-2 flex-fill d-flex flex-column mb-0">
<h6 class="mb-auto">Market Price</h6>
<p class="mb-0 mt-1">${price.marketPrice}</p>
@@ -361,15 +352,15 @@ const altSearchUrl = (card: any) => {
</div>
);
})}
{/* {hasAccess && ( */}
{canAddInventory && (
<div class="tab-pane fade" id="nav-vendor" role="tabpanel" aria-labelledby="nav-vendor" tabindex="0">
<div class="row g-4">
<div class="row g-5">
<div class="col-12 col-md-6">
<h6 class="mt-1 mb-2">Add {card?.productName} to inventory</h6>
<form id="inventoryForm" data-inventory-form novalidate>
<div class="row gx-3 gy-1">
<div class="col-3">
<div class="col-12 col-lg-3">
<label for="quantity" class="form-label">Quantity</label>
<input
type="number"
@@ -384,7 +375,7 @@ const altSearchUrl = (card: any) => {
<div class="invalid-feedback">Required.</div>
</div>
<div class="col-9">
<div class="col-12 col-lg-9">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<label for="purchasePrice" class="form-label">
Purchase price
@@ -436,7 +427,7 @@ const altSearchUrl = (card: any) => {
<div class="col-12">
<label class="form-label">Condition</label>
<div class="btn-group condition-input w-100" role="group" aria-label="Condition">
<div class="btn-group btn-group-sm condition-input w-100 col-12" role="group" aria-label="Condition">
<input
type="radio"
class="btn-check"
@@ -521,7 +512,7 @@ const altSearchUrl = (card: any) => {
class="form-control"
id="note"
name="note"
rows="2"
rows="3"
maxlength="255"
placeholder="e.g. bought at local shop, gift, graded copy…"
></textarea>
@@ -554,7 +545,7 @@ const altSearchUrl = (card: any) => {
</div>
</div>
</div>
{/* )} */}
)}
</div>
<!-- Chart lives permanently outside tab-content so Bootstrap never hides it. -->

View File

@@ -1,23 +1,13 @@
---
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;
import * as util from 'util';
// auth check for inventory management features
const { canAddInventory } = Astro.locals;
// all the facet fields we want to use for filtering
const facetFields:any = {
@@ -225,9 +215,9 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
<span id="sortLabel" class="ms-1 text-secondary small">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
<div class="btn-group btn-group-sm ms-2 flex-shrink-0" role="group" aria-label="Language filter">
<button type="button" class={`btn btn-dark language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
<button type="button" class={`btn btn-dark language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
<button type="button" class={`btn btn-dark language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
</div>
</div>
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
@@ -296,11 +286,11 @@ const facets = searchResults.results.slice(1).map((result: any) => {
{pokemon.map((card:any) => (
<div class="col">
{/* {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>
{canAddInventory && (
<button type="button" class="btn btn-sm inventory-button position-relative float-end shadow-filter text-center p-2 fw-bold" 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');">
+/
</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>
@@ -315,13 +305,13 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div>
))}
</div>
<div class="h5 my-0">{card.productName}</div>
<div class="fs-5 fw-semibold my-0">{card.productName}</div>
<div class="d-flex flex-row lh-1 mt-1 justify-content-between">
<div class="text-secondary flex-grow-1 d-none d-lg-flex">{card.setName}</div>
<div class="text-body-tertiary">{card.number}</div>
<div class="text-body-tertiary flex-grow-1 d-none d-lg-flex fst-normal">{card.setName}</div>
<div class="text-body-tertiary fst-normal">{card.number}</div>
<span class="ps-2 small-icon"><RarityIcon rarity={card.rarityName} /></span>
</div>
<div class="text-body-tertiary">{card.variant}</div><span class="d-none">{card.productId}</span>
<div class="text-secondary fst-italic">{card.variant}</div><span class="d-none">{card.productId}</span>
</div>
))}

View File

@@ -84,17 +84,17 @@ console.log(`totalHits: ${totalHits}`);
</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">
<input type="number" class="form-control text-center" style="max-width: 33%;" 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>
<button type="button" class="btn btn-outline-warning me-2"><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"><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 class="p fs-7 text-body-tertiary">{card.setName}</div>
</div>
<div class="d-flex flex-row mt-1">
<div class="h5">{card.productName}</div>
<div class="fs-6 fw-semibold my-0">{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"}`}>

View File

@@ -1,14 +1,9 @@
---
export const prerender = false;
import Layout from '../layouts/Main.astro';
import Search from '../components/Search.astro';
import CardGrid from "../components/CardGrid.astro";
import NavBar from '../components/NavBar.astro';
---
<Layout title="Card Search">
<NavBar slot="navbar">
<Search slot="searchInput" />
</NavBar>
<CardGrid slot="page" />
<CardGrid slot="page"/>
</Layout>

24
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,24 @@
---
export const prerender = false;
import Footer from '../components/Footer.astro';
import Layout from '../layouts/Main.astro';
---
<Layout title="Terms and Privacy" >
<div class="container-fluid container-sm my-5" slot="page">
<section class="legal-info p-6 bg-gray-50 text-gray-800 rounded-md space-y-3">
<h2 class="text-xl font-semibold">Privacy & Terms</h2>
<p>
By signing in with your Google account, you agree to the following Terms of Service and Privacy Policy:
</p>
<p>
<strong>Terms of Service:</strong> You may use our services only as permitted by law. Your account is for personal use and must be kept secure. We reserve the right to modify or discontinue services at any time. Misuse of the service may result in account suspension.
</p>
<p>
<strong>Privacy Policy:</strong> We use Clerk.js to handle authentication, ensuring that your Google account information is securely processed. We do not store your Google password. Personal data is only used to provide and improve our services. We do not sell your information to third parties. You have the right to access, correct, or delete your data by contacting us.
</p>
</div>
<Footer slot="footer" />
</Layout>

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-28885955-490e-4e5c-bba8-d29f831e6862" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 984.04107 353.35931"><g id="uuid-83941b04-85a7-4465-b9e0-012c209dc6a7"><path d="M653.62926,104.05915v32.23634h89.02801v217.06382h152.81484v-217.06382h88.56896V0h-330.4118v104.05915" style="fill:#fcf5f3;"/><g id="uuid-43578a3f-54c5-443e-9235-237fee6994bd"><path d="M262.03609,194.11787c12.84855-3.67235,23.01598-8.79185,30.51628-15.37407,7.49408-6.57599,13.07885-14.06852,16.7512-22.48691,3.67079-8.41061,6.03603-16.90058,7.11284-25.46836,1.06747-8.56155,1.60587-16.5209,1.60587-23.86404,0-25.99898-4.89698-46.80844-14.68473-62.41126-9.79552-15.60281-22.87438-26.91707-39.23656-33.95833C247.73105,3.51985,229.75366,0,210.17973,0H0v224.43011c2.52455-6.88838,5.14382-13.5023,7.8372-19.64065,18.36155-41.84105,49.28436-78.81895,78.08584-103.94867,28.79857-25.12952,75.6109-42.0873,99.07464-43.44673,23.46355-1.35962,37.54529,13.10025,43.17616,23.29335,5.63106,10.19291,4.89232,45.84328-18.69786,69.97672-23.59309,24.13363-53.42645,29.7079-70.68143,25.4174-17.25206-4.2907-23.29685-19.71592-21.08936-33.74282,2.20749-14.02709,13.38326-30.67734,26.43002-37.99014,13.04968-7.3126,24.90662-7.22157,30.40541-.71424,5.49588,6.50714-3.95517,14.38071-3.10146,16.03482.8537,1.6545,8.51098-4.88337,8.01945-13.10783-.49425-8.22447-11.28761-17.50765-32.61563-8.89591-21.33094,8.61193-32.92335,38.51084-32.92335,38.51084,0,0-44.99289,21.68242-70.52913,55.88934C23.29374,218.98539,8.70899,252.47748,0,290.56468v62.79464h144.55516v-94.53497h4.12984l24.78213,94.53497h161.07607l-72.5071-159.24145Z" style="fill:#fcf5f3;"/></g><path d="M615.38918,0h-189.98648l-90.86418,353.35931h148.685l4.59044-42.21956h65.16396l4.58888,42.21956h148.68656L615.38918,0ZM561.10669,138.27405l-14.62209,89.89785c-.2126.75042-.89766,1.26859-1.67765,1.26859h-17.93032c-.84476,0-1.56814-.6057-1.71655-1.43723l-3.03397-37.78104c-.15269-.85468-.91089-1.46699-1.7786-1.43607-1.01009.03598-1.77821.92062-1.67181,1.92584l-3.6422,36.80149c.10893,1.02974-.69849,1.92701-1.73386,1.92701h-19.2526c-.86888,0-1.6051-.63974-1.72647-1.50006l-12.68478-89.89785c-.14841-1.05172.67009-1.99081,1.73231-1.98711h78.06678c1.15422.00389,1.98614,1.10793,1.67181,2.21858Z" style="fill:#fcf5f3;"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB