Add Trick Glossary + harden Sasquatch AI rate-limit handling

Glossary:
- New /glossary page: 80+ tricks across 11 categories (fundamentals,
  flips, shoves/spins, grinds, slides, grabs, airs, lip, stalls,
  handplants, boneless, freestyle) with alternate names, difficulty
  badges, live search, and category filters.
- Added to desktop "More" dropdown and mobile burger menu.

Sasquatch AI:
- Fixed/expanded free-model fallback list with valid OpenRouter slugs
  (dropped the non-existent gemma-4 slug).
- Added exponential backoff + one retry on 429 rate limits, respecting
  Retry-After headers.
- SasquatchGuide now reuses the shared sasquatchBrain caller instead of
  a duplicated implementation.
- Friendlier fallback message when the AI is saturated.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-29 22:39:51 +07:00
parent e6517b5c70
commit a371ff3645
6 changed files with 537 additions and 72 deletions

View file

@ -56,6 +56,7 @@ const RiderCardPage = lazy(() => import("./pages/RiderCardPage"));
const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage")); const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage"));
const MarketplacePage = lazy(() => import("./pages/MarketplacePage")); const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
const NewsletterPage = lazy(() => import("./pages/NewsletterPage")); const NewsletterPage = lazy(() => import("./pages/NewsletterPage"));
const GlossaryPage = lazy(() => import("./pages/GlossaryPage"));
const NotFound = lazy(() => import("./pages/NotFound")); const NotFound = lazy(() => import("./pages/NotFound"));
// Minimal inline fallback — no extra component needed // Minimal inline fallback — no extra component needed
@ -136,6 +137,8 @@ export function AppRouter() {
<Route path="/marketplace-listings" element={<MarketplacePage />} /> <Route path="/marketplace-listings" element={<MarketplacePage />} />
{/* Newsletter */} {/* Newsletter */}
<Route path="/newsletter" element={<NewsletterPage />} /> <Route path="/newsletter" element={<NewsletterPage />} />
{/* Trick Glossary */}
<Route path="/glossary" element={<GlossaryPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} /> <Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View file

@ -49,6 +49,7 @@ import {
Radio, Radio,
Users, Users,
SlidersHorizontal, SlidersHorizontal,
BookOpen,
} from 'lucide-react'; } from 'lucide-react';
import { OfflineIndicator } from '@/components/OfflineIndicator'; import { OfflineIndicator } from '@/components/OfflineIndicator';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -474,6 +475,12 @@ export function Layout({ children, noPadding }: LayoutProps) {
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/glossary" className="flex items-center gap-2 cursor-pointer">
<BookOpen className="h-4 w-4" />
Glossary
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/about" className="flex items-center gap-2 cursor-pointer"> <Link to="/about" className="flex items-center gap-2 cursor-pointer">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
@ -713,6 +720,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
isActive={isActive('/stats')} isActive={isActive('/stats')}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
<NavLink
href="/glossary"
label="Glossary"
icon={BookOpen}
isActive={isActive('/glossary')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink <NavLink
href="/about" href="/about"
label={t('nav.about')} label={t('nav.about')}

View file

@ -15,6 +15,7 @@ import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-r
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCVMClient } from '@/hooks/useCVMClient'; import { useCVMClient } from '@/hooks/useCVMClient';
import { callSasquatchAI } from '@/lib/sasquatchBrain';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { fetchWeather, calculateSkateability } from '@/lib/weather'; import { fetchWeather, calculateSkateability } from '@/lib/weather';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
@ -54,52 +55,9 @@ async function fetchWeatherForCity(city: string): Promise<string> {
}); });
} }
// ─── OpenRouter free model — no credits, no NIP-98, works for everyone ──────── // AI calls route through the shared Sasquatch brain (sasquatchBrain.ts), which
const OPENROUTER_API_KEY = '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'; // handles the free-model fallback list + rate-limit backoff/retry in one place.
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; const callAI = callSasquatchAI;
const FREE_MODEL = 'meta-llama/llama-3.3-70b-instruct:free';
// Try multiple free models in order — if one is rate-limited, fall back to the next
const FREE_MODELS = [
'openai/gpt-oss-20b:free', // Fast, reliable
'google/gemma-4-31b-it:free', // Good fallback
'meta-llama/llama-3.3-70b-instruct:free', // Best quality, sometimes rate-limited
];
async function callAI(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise<string> {
let lastError = '';
for (const model of FREE_MODELS) {
try {
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://skate-spots.org',
'X-Title': 'SkateSpots',
},
body: JSON.stringify({
model,
messages,
max_tokens: 400,
}),
});
if (!response.ok) {
const err = await response.text();
console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err);
lastError = `${response.status}: ${err}`;
continue; // try next model
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? '';
if (content) return content;
} catch (err) {
console.warn(`[Sasquatch] model ${model} threw:`, err);
lastError = String(err);
}
}
throw new Error(`All AI models failed. Last error: ${lastError}`);
}
export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab'; export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab';
@ -597,7 +555,7 @@ Instructions:
if (!aiSucceeded) { if (!aiSucceeded) {
const fallback = staticAnswer ?? const fallback = staticAnswer ??
"The AI is a bit slow right now (free tier rate limits). Try again in a moment, or check the 🗺️ Map for spots, 🧪 The Lab for coaches. 🛹"; "My brain's catching a lot of traffic right now 🦧 — give it a few seconds and ask again. Meanwhile you can hit the 🗺️ Map for spots, 🧪 The Lab for coaches, or the 📖 Glossary to level up your trick vocab. 🛹";
appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() }); appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
} }
} catch (err) { } catch (err) {

239
src/lib/glossary.ts Normal file
View file

@ -0,0 +1,239 @@
/**
* Skateboarding trick glossary.
* ─────────────────────────────────────────────────────────────────────────────
* A curated, categorized reference of skate tricks with descriptions and their
* common alternate names (tricks pick up regional / era-specific nicknames).
*
* Static content — no network, no Nostr. Purely a learning reference for the
* community. Keep entries concise and skater-accurate.
*/
export interface Trick {
/** Canonical name. */
name: string;
/** Other names the same trick is known by. */
aka?: string[];
/** Short, skater-accurate description. */
desc: string;
/** Difficulty hint for filtering. */
level?: 'beginner' | 'intermediate' | 'advanced';
}
export interface TrickCategory {
id: string;
/** Display title. */
title: string;
/** One-line description of the category. */
blurb: string;
/** Emoji used as a lightweight category glyph. */
glyph: string;
tricks: Trick[];
}
export const GLOSSARY: TrickCategory[] = [
{
id: 'fundamentals',
title: 'Fundamentals',
blurb: 'The building blocks every trick is stacked on.',
glyph: '🛹',
tricks: [
{ name: 'Ollie', desc: 'The foundational jump — pop the tail, slide the front foot, and lift the board with no hands.', level: 'beginner' },
{ name: 'Nollie', aka: ['Nose Ollie'], desc: 'An ollie popped off the nose instead of the tail, riding forward.', level: 'intermediate' },
{ name: 'Fakie Ollie', desc: 'An ollie performed while rolling backwards (fakie stance).', level: 'beginner' },
{ name: 'Switch Ollie', desc: 'An ollie done in your non-natural (switch) stance.', level: 'intermediate' },
{ name: 'Pop Shove-it', aka: ['Pop Shuv', 'Shove-it'], desc: 'Spin the board 180° under your feet without flipping it, with pop.', level: 'beginner' },
{ name: 'Manual', aka: ['Wheelie'], desc: 'Balance rolling on the back two wheels; nose manual uses the front two.', level: 'beginner' },
{ name: 'Powerslide', desc: 'Turn the board perpendicular and slide the wheels to scrub speed.', level: 'beginner' },
{ name: 'Boardslide', desc: 'Slide along an obstacle with the board perpendicular, deck contacting the edge.', level: 'intermediate' },
],
},
{
id: 'flips',
title: 'Flip Tricks',
blurb: 'Tricks where the board rotates around one or more axes.',
glyph: '🌀',
tricks: [
{ name: 'Kickflip', aka: ['Magic Flip (original name)'], desc: 'Flick the board so it flips one full rotation along its length toward the heel side of the toes.', level: 'intermediate' },
{ name: 'Heelflip', desc: 'Like a kickflip but flicked off the heel, flipping the opposite direction.', level: 'intermediate' },
{ name: 'Varial Kickflip', desc: 'A kickflip combined with a backside pop shove-it.', level: 'intermediate' },
{ name: 'Varial Heelflip', desc: 'A heelflip combined with a frontside pop shove-it.', level: 'intermediate' },
{ name: '360 Flip', aka: ['Tre Flip', '360 Kickflip', 'Three-Sixty Flip'], desc: 'A kickflip combined with a 360° backside pop shove-it — a signature street trick.', level: 'advanced' },
{ name: 'Hardflip', desc: 'A frontside pop shove-it combined with a kickflip; the board rotates vertically through the legs.', level: 'advanced' },
{ name: 'Inward Heelflip', desc: 'A backside pop shove-it combined with a heelflip.', level: 'advanced' },
{ name: 'Laser Flip', desc: 'A frontside 360 pop shove-it combined with a heelflip — the mirror of a tre flip.', level: 'advanced' },
{ name: 'Double Kickflip', desc: 'A kickflip that rotates twice before you catch it.', level: 'advanced' },
],
},
{
id: 'shoves',
title: 'Shove-its & Spins',
blurb: 'Board and body rotations without a flip.',
glyph: '🔄',
tricks: [
{ name: 'Shove-it', aka: ['Shuvit'], desc: 'Scoop the board 180° flat under your feet without popping.', level: 'beginner' },
{ name: 'Frontside Shove-it', desc: 'A shove-it where the board spins frontside (toward your toes).', level: 'beginner' },
{ name: '360 Shove-it', aka: ['360 Shuv'], desc: 'A shove-it that spins the board a full 360°.', level: 'intermediate' },
{ name: 'Frontside 180', aka: ['FS 180'], desc: 'An ollie with a 180° body-and-board rotation frontside.', level: 'intermediate' },
{ name: 'Backside 180', aka: ['BS 180'], desc: 'An ollie with a 180° body-and-board rotation backside.', level: 'intermediate' },
{ name: 'Bigspin', desc: 'A 360° board shove-it combined with a 180° body rotation, same direction.', level: 'advanced' },
{ name: 'Bigflip', desc: 'A bigspin with a kickflip added.', level: 'advanced' },
],
},
{
id: 'grinds',
title: 'Grinds',
blurb: 'Sliding along an edge on the trucks.',
glyph: '⚙️',
tricks: [
{ name: '50-50', aka: ['Fifty-Fifty'], desc: 'Grind on both trucks equally along a rail or ledge.', level: 'intermediate' },
{ name: '5-0', aka: ['Five-O'], desc: 'Grind on the back truck only, nose up (like a manual on an edge).', level: 'intermediate' },
{ name: 'Nosegrind', desc: 'Grind on the front truck only, tail up.', level: 'intermediate' },
{ name: 'Crooked Grind', aka: ['K-Grind', 'Crooks'], desc: 'A nosegrind angled so the nose also slides on the edge.', level: 'advanced' },
{ name: 'Smith Grind', desc: 'Back truck grinds while the front dips below the edge, nose pointed down.', level: 'advanced' },
{ name: 'Feeble Grind', desc: 'Back truck grinds with the front truck hanging over the far side, nose down.', level: 'advanced' },
{ name: 'Overcrook', aka: ['Overcrooked'], desc: 'A crooked grind on the far side of the rail with the nose overhanging.', level: 'advanced' },
{ name: 'Salad Grind', desc: 'A 5-0 variation with the back truck grinding and rear wheels on the far side.', level: 'advanced' },
],
},
{
id: 'slides',
title: 'Slides',
blurb: 'Sliding on the deck instead of the trucks.',
glyph: '➖',
tricks: [
{ name: 'Boardslide', desc: 'Slide with the middle of the deck perpendicular across the edge.', level: 'intermediate' },
{ name: 'Lipslide', desc: 'Like a boardslide but the back of the board clears the obstacle first.', level: 'advanced' },
{ name: 'Noseslide', desc: 'Slide on the nose of the board along a ledge.', level: 'intermediate' },
{ name: 'Tailslide', desc: 'Slide on the tail of the board along a ledge.', level: 'advanced' },
{ name: 'Bluntslide', desc: 'Slide with the back wheels up on the ledge and tail on the surface.', level: 'advanced' },
{ name: 'Darkslide', desc: 'Slide upside-down on the grip tape — an advanced flip-in slide.', level: 'advanced' },
],
},
{
id: 'grabs',
title: 'Grabs',
blurb: 'Airborne tricks where a hand grabs the board.',
glyph: '✋',
tricks: [
{ name: 'Indy', aka: ['Indy Grab'], desc: 'Trailing hand grabs the toe edge between the feet.', level: 'intermediate' },
{ name: 'Melon', aka: ['Melancholie'], desc: 'Leading hand grabs the heel edge between the feet.', level: 'intermediate' },
{ name: 'Mute', aka: ['Mute Grab'], desc: 'Leading hand grabs the toe edge; named after deaf skater Chris Weddle.', level: 'intermediate' },
{ name: 'Nosegrab', desc: 'Grab the nose of the board in the air.', level: 'intermediate' },
{ name: 'Tailgrab', desc: 'Grab the tail of the board in the air.', level: 'intermediate' },
{ name: 'Stalefish', desc: 'Trailing hand grabs the heel edge behind the back leg.', level: 'advanced' },
{ name: 'Method', aka: ['Method Air'], desc: 'Heel-edge grab with the board pulled up behind you — a stylish, arched air.', level: 'advanced' },
{ name: 'Nosebone', desc: 'A nose grab with the front leg boned out straight.', level: 'advanced' },
],
},
{
id: 'airs',
title: 'Airs (Vert / Transition)',
blurb: 'Big air tricks off ramps, bowls, and quarterpipes.',
glyph: '🚀',
tricks: [
{ name: 'Air', aka: ['Aerial'], desc: 'Any launch off a transition with the board leaving the coping.', level: 'intermediate' },
{ name: 'Backside Air', desc: 'An air rotating backside out of the transition, usually grabbed.', level: 'advanced' },
{ name: 'Frontside Air', desc: 'An air rotating frontside out of the transition.', level: 'advanced' },
{ name: '540', aka: ['Five-Forty', 'McTwist (with flip)'], desc: 'A one-and-a-half rotation air; the McTwist adds a frontside flip.', level: 'advanced' },
{ name: '900', desc: 'Two-and-a-half rotations — famously landed by Tony Hawk in 1999.', level: 'advanced' },
{ name: 'Judo Air', desc: 'A grab air with the front foot kicked off and out to the side.', level: 'advanced' },
],
},
{
id: 'lip',
title: 'Lip Tricks',
blurb: 'Tricks performed on the coping / lip of a ramp or bowl.',
glyph: '👄',
tricks: [
{ name: 'Rock to Fakie', aka: ['Rock n Roll to Fakie'], desc: 'Ride up, rock the board over the coping, then roll back in fakie.', level: 'intermediate' },
{ name: 'Rock n Roll', desc: 'Rock over the coping then pivot 180° to roll back in forward.', level: 'intermediate' },
{ name: 'Axle Stall', aka: ['50-50 Stall'], desc: 'Stall on both trucks on the coping, then drop back in.', level: 'intermediate' },
{ name: 'Feeble Stall', desc: 'A feeble grind held as a stall on the coping.', level: 'advanced' },
{ name: 'Blunt to Fakie', desc: 'Stall in a blunt position on the coping then come back in fakie.', level: 'advanced' },
{ name: 'Disaster', desc: 'Ollie over the coping so the middle of the board rests on the lip.', level: 'advanced' },
{ name: 'Smith Stall', desc: 'A smith grind held as a stall on the coping before dropping back in.', level: 'advanced' },
],
},
{
id: 'stalls',
title: 'Stalls',
blurb: 'Pausing balanced on an obstacle before re-entering.',
glyph: '⏸️',
tricks: [
{ name: 'Nose Stall', desc: 'Balance stalled on the nose against a ledge or coping.', level: 'intermediate' },
{ name: 'Tail Stall', desc: 'Balance stalled on the tail on the coping.', level: 'intermediate' },
{ name: 'Pivot Stall', desc: 'Stall balanced on the back truck and pivot to re-enter.', level: 'advanced' },
{ name: 'Nose Pickup', aka: ['Nose Stall Pickup'], desc: 'Stall on the nose then pick the board back up to roll away.', level: 'advanced' },
],
},
{
id: 'handplants',
title: 'Handplants & Inverts',
blurb: 'One-hand-on-the-coping inverted tricks — old-school flair.',
glyph: '🤸',
tricks: [
{ name: 'Invert', aka: ['Handplant', 'One-Hand Plant'], desc: 'Plant one hand on the coping and balance inverted with the board grabbed.', level: 'advanced' },
{ name: 'Andrecht', desc: 'A backside invert grabbing heel-side with the plant hand behind.', level: 'advanced' },
{ name: 'Eggplant', desc: 'A frontside invert planting the leading hand.', level: 'advanced' },
{ name: 'Sad Plant', desc: 'A handplant variation with a straightened, "sad" board position.', level: 'advanced' },
{ name: 'Ho-Ho', aka: ['Two-Hand Plant'], desc: 'A handstand-style invert balanced on both hands.', level: 'advanced' },
],
},
{
id: 'boneless',
title: 'Boneless & Foot-Plants',
blurb: 'Grab-and-jump tricks planting a foot on the ground.',
glyph: '🦴',
tricks: [
{ name: 'Boneless', desc: 'Grab the board, plant the front foot on the ground, and jump back on.', level: 'intermediate' },
{ name: 'No Comply', desc: 'Step the front foot off, pop the tail so the board spins/hops, then reload.', level: 'intermediate' },
{ name: 'Bean Plant', desc: 'A boneless variation planting the back foot and grabbing with the leading hand.', level: 'intermediate' },
{ name: 'Nose Bonk', desc: 'Tap the nose off an obstacle mid-line for style.', level: 'beginner' },
],
},
{
id: 'freestyle',
title: 'Freestyle',
blurb: 'Flatground balance and pivot tricks from skate’s roots.',
glyph: '🎪',
tricks: [
{ name: 'Casper', aka: ['Casper Slide'], desc: 'Flip the board upside-down and balance on the tail with one foot under the deck.', level: 'advanced' },
{ name: 'Primo', aka: ['Primo Slide'], desc: 'Balance the board on its side (rail) and stand on it.', level: 'advanced' },
{ name: 'Rail Stand', aka: ['Primo Stand'], desc: 'Stand on the upturned rail of the board — classic freestyle balance.', level: 'advanced' },
{ name: 'Truckstand', desc: 'Balance on one truck with the board tilted vertically.', level: 'advanced' },
{ name: 'Pogo', desc: 'Hop on the tail with the board vertical, holding the nose.', level: 'advanced' },
{ name: 'Handstand', aka: ['Handstand Fingerflip'], desc: 'A handstand on the board, often flipped out — a freestyle staple.', level: 'advanced' },
],
},
];
/** Total trick count across all categories (for headers/stats). */
export const TOTAL_TRICKS = GLOSSARY.reduce((sum, c) => sum + c.tricks.length, 0);
/** Flat, searchable list joining name + aka + description + category. */
export interface FlatTrick extends Trick {
categoryId: string;
categoryTitle: string;
categoryGlyph: string;
}
export const ALL_TRICKS: FlatTrick[] = GLOSSARY.flatMap((cat) =>
cat.tricks.map((t) => ({
...t,
categoryId: cat.id,
categoryTitle: cat.title,
categoryGlyph: cat.glyph,
})),
);
/** Case-insensitive search across name, aka, and description. */
export function searchTricks(query: string): FlatTrick[] {
const q = query.trim().toLowerCase();
if (!q) return ALL_TRICKS;
return ALL_TRICKS.filter((t) => {
if (t.name.toLowerCase().includes(q)) return true;
if (t.desc.toLowerCase().includes(q)) return true;
if (t.aka?.some((a) => a.toLowerCase().includes(q))) return true;
return false;
});
}

View file

@ -9,18 +9,35 @@ const OPENROUTER_API_KEY = 'sk-or-v1-ce29b4c58cd842212e4b2f1fae7eb31ab85ce93870b
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
// Try multiple free models in order — if one is rate-limited, fall back. // Try multiple free models in order — if one is rate-limited, fall back.
// Slugs verified against OpenRouter's free tier. Ordered by reliability +
// availability so the first pick is usually the one that answers. If any is
// rate-limited (429) we back off briefly then move to the next model.
export const FREE_MODELS = [ export const FREE_MODELS = [
'openai/gpt-oss-20b:free',
'google/gemma-4-31b-it:free',
'meta-llama/llama-3.3-70b-instruct:free', 'meta-llama/llama-3.3-70b-instruct:free',
'google/gemma-2-9b-it:free',
'mistralai/mistral-7b-instruct:free',
'qwen/qwen-2.5-7b-instruct:free',
'openai/gpt-oss-20b:free',
]; ];
export type ChatTurn = { role: 'user' | 'assistant'; content: string }; export type ChatTurn = { role: 'user' | 'assistant'; content: string };
/** Call the AI with model fallback. Throws only if every model fails. */ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Call the AI with model fallback + light retry on rate limits.
*
* Strategy: for each model, try once; on a 429 (rate limit) wait a short,
* increasing beat and retry that same model once before moving on. This
* smooths over the free tier's bursty limits without hammering the API.
* Throws only if every model fails after its retries.
*/
export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise<string> { export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise<string> {
let lastError = ''; let lastError = '';
for (const model of FREE_MODELS) { for (const model of FREE_MODELS) {
// Up to 2 attempts per model (initial + 1 retry after backoff on 429).
for (let attempt = 0; attempt < 2; attempt++) {
try { try {
const response = await fetch(OPENROUTER_URL, { const response = await fetch(OPENROUTER_URL, {
method: 'POST', method: 'POST',
@ -32,20 +49,37 @@ export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Pr
}, },
body: JSON.stringify({ model, messages, max_tokens: maxTokens }), body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
}); });
if (response.status === 429 && attempt === 0) {
// Rate limited — respect Retry-After if present, else short backoff.
const retryAfter = Number(response.headers.get('retry-after'));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, 2500)
: 600;
console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`);
await sleep(waitMs);
continue; // retry same model
}
if (!response.ok) { if (!response.ok) {
const err = await response.text(); const err = await response.text();
console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err);
lastError = `${response.status}: ${err}`; lastError = `${response.status}: ${err}`;
continue; break; // move to next model
} }
const data = await response.json(); const data = await response.json();
const content = data.choices?.[0]?.message?.content ?? ''; const content = data.choices?.[0]?.message?.content ?? '';
if (content) return content; if (content) return content;
break; // empty content — try next model
} catch (err) { } catch (err) {
console.warn(`[Sasquatch] model ${model} threw:`, err); console.warn(`[Sasquatch] model ${model} threw:`, err);
lastError = String(err); lastError = String(err);
break; // network error — try next model
} }
} }
}
throw new Error(`All AI models failed. Last error: ${lastError}`); throw new Error(`All AI models failed. Last error: ${lastError}`);
} }

217
src/pages/GlossaryPage.tsx Normal file
View file

@ -0,0 +1,217 @@
import { useState, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
GLOSSARY,
TOTAL_TRICKS,
searchTricks,
type FlatTrick,
} from '@/lib/glossary';
import { BookOpen, Search, X } from 'lucide-react';
const LEVEL_STYLES: Record<string, string> = {
beginner: 'bg-green-500/15 text-green-400 border-green-500/30',
intermediate: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
advanced: 'bg-red-500/15 text-red-400 border-red-500/30',
};
function TrickRow({ trick }: { trick: { name: string; aka?: string[]; desc: string; level?: string } }) {
return (
<div className="rounded-xl border border-white/8 bg-white/[0.02] p-4 transition-colors hover:border-red-500/25 hover:bg-white/[0.04]">
<div className="flex items-start justify-between gap-3">
<h3 className="text-base font-black text-white tracking-tight">{trick.name}</h3>
{trick.level && (
<Badge
variant="outline"
className={cn('shrink-0 text-[10px] font-bold uppercase tracking-wider', LEVEL_STYLES[trick.level])}
>
{trick.level}
</Badge>
)}
</div>
{trick.aka && trick.aka.length > 0 && (
<p className="mt-1 text-xs text-red-400/80">
<span className="text-gray-500">a.k.a. </span>
{trick.aka.join(' · ')}
</p>
)}
<p className="mt-2 text-sm leading-relaxed text-gray-300">{trick.desc}</p>
</div>
);
}
export default function GlossaryPage() {
const [query, setQuery] = useState('');
const [activeCat, setActiveCat] = useState<string | null>(null);
useSeoMeta({
title: 'Trick Glossary · SkateSpots',
description: `The complete skateboarding trick glossary — ${TOTAL_TRICKS}+ flips, grinds, slides, grabs, airs, lip tricks, stalls, handplants, freestyle & more, with alternate names.`,
});
const isSearching = query.trim().length > 0;
const searchResults: FlatTrick[] = useMemo(
() => (isSearching ? searchTricks(query) : []),
[query, isSearching],
);
const visibleCategories = useMemo(
() => (activeCat ? GLOSSARY.filter((c) => c.id === activeCat) : GLOSSARY),
[activeCat],
);
return (
<Layout>
<div className="min-h-screen">
{/* Hero */}
<div className="relative overflow-hidden border-b border-red-500/20 bg-gradient-to-b from-red-950/20 to-transparent">
<div className="container px-4 py-10 md:py-14">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-500/15 text-red-400 shadow-lg shadow-red-500/20">
<BookOpen className="h-6 w-6" />
</div>
<div>
<h1
className="font-display text-3xl md:text-4xl neon-red tracking-wide"
style={{ textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040' }}
>
TRICK GLOSSARY
</h1>
<p className="text-sm text-gray-400">
{TOTAL_TRICKS} tricks · flips, grinds, slides, grabs, airs, lip &amp; more
</p>
</div>
</div>
{/* Search */}
<div className="relative mt-6 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tricks or nicknames — tre flip, crooks, method…"
className="h-11 rounded-xl border-white/10 bg-black/40 pl-9 pr-9 text-white placeholder:text-gray-500 focus-visible:ring-red-500/40"
/>
{query && (
<button
onClick={() => setQuery('')}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
<div className="container px-4 py-8">
{/* Category filter pills — hidden while searching */}
{!isSearching && (
<div className="mb-8 flex flex-wrap gap-2">
<button
onClick={() => setActiveCat(null)}
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-bold uppercase tracking-wide transition-colors',
activeCat === null
? 'border-red-500/50 bg-red-500/15 text-red-400'
: 'border-white/10 text-gray-400 hover:border-white/25 hover:text-white',
)}
>
All
</button>
{GLOSSARY.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCat(cat.id)}
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-bold uppercase tracking-wide transition-colors',
activeCat === cat.id
? 'border-red-500/50 bg-red-500/15 text-red-400'
: 'border-white/10 text-gray-400 hover:border-white/25 hover:text-white',
)}
>
<span className="mr-1">{cat.glyph}</span>
{cat.title}
</button>
))}
</div>
)}
{/* Search results */}
{isSearching ? (
<div>
<p className="mb-4 text-sm text-gray-400">
{searchResults.length} result{searchResults.length === 1 ? '' : 's'} for
<span className="ml-1 font-bold text-white">“{query}”</span>
</p>
{searchResults.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 p-10 text-center">
<p className="text-gray-500">
No tricks match that. Try a nickname like “tre flip”, “crooks”, or “melon”.
</p>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{searchResults.map((t) => (
<div key={`${t.categoryId}-${t.name}`}>
<p className="mb-1 text-[10px] font-bold uppercase tracking-widest text-gray-600">
{t.categoryGlyph} {t.categoryTitle}
</p>
<TrickRow trick={t} />
</div>
))}
</div>
)}
</div>
) : (
/* Category sections */
<div className="space-y-12">
{visibleCategories.map((cat) => (
<section key={cat.id} id={cat.id}>
<div className="mb-4 flex items-baseline gap-3">
<h2 className="text-xl font-black uppercase tracking-wide text-white">
<span className="mr-2">{cat.glyph}</span>
{cat.title}
</h2>
<span className="text-xs text-gray-500">{cat.tricks.length} tricks</span>
</div>
<p className="mb-5 text-sm text-gray-400">{cat.blurb}</p>
<div className="grid gap-3 md:grid-cols-2">
{cat.tricks.map((t) => (
<TrickRow key={t.name} trick={t} />
))}
</div>
</section>
))}
</div>
)}
{/* Footer note */}
<div className="mt-14 rounded-2xl border border-white/8 bg-white/[0.02] p-5 text-center">
<p className="text-sm text-gray-400">
Know a trick or nickname we missed? Tricks pick up different names in every
scene — hit up{' '}
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent('sasquatch:ask', {
detail: { question: 'How do I suggest a trick for the glossary?' },
}),
)
}
className="font-bold text-red-400 hover:text-red-300"
>
The Sasquatch 🦧
</button>{' '}
and let us know.
</p>
</div>
</div>
</div>
</Layout>
);
}