Refactor bike builder into Next.js app

Replace the single JSX file with a Next.js project, shared catalog and recommendation modules, backend API routes, file-backed persistence, Ollama recommendation endpoint, and picture-layer bike composition. Add initial aligned component assets for existing BSU layers plus RockShox and Maxxis products.
This commit is contained in:
2026-05-19 12:56:41 +02:00
parent 8982fcd42c
commit b307919bf7
45 changed files with 8205 additions and 1309 deletions
+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL="file:./dev.db"
OLLAMA_URL="http://localhost:11434"
OLLAMA_MODEL="llama3.1:8b"
+11
View File
@@ -0,0 +1,11 @@
BSU/
node_modules/
.next/
dist/
*.log
.env
prisma/dev.db
data/*.db
data/*.json
public/images/user/*.png
tmp/
+10
View File
@@ -0,0 +1,10 @@
import nextVitals from "eslint-config-next/core-web-vitals";
export default [
...nextVitals,
{
rules: {
"@next/next/no-img-element": "off",
},
},
];
-1309
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+7193
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"scripts":{"dev":"next dev","build":"next build","start":"next start","lint":"eslint src","db:seed":"tsx prisma/seed.ts"},"dependencies":{"@prisma/client":"^5.22.0","next":"^16.2.6","postcss":"^8.5.10","prisma":"^5.22.0","react":"^19.2.1","react-dom":"^19.2.1","sharp":"^0.33.5","zod":"^3.23.8"},"devDependencies":{"@types/node":"^20.17.6","@types/react":"^19.2.7","@types/react-dom":"^19.2.3","eslint":"^9.39.4","eslint-config-next":"^16.2.6","tsx":"^4.19.2","typescript":"^5.6.3"}}
+68
View File
@@ -0,0 +1,68 @@
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Product {
id String @id
category String
position String?
brand String
model String
imagePath String?
specs String
setup String
sources String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BikeBuild {
id String @id @default(cuid())
name String
riderWeightKg Int
discipline String
riderLevel String
rideStyle String
frameId String?
forkId String?
shockId String?
frontTireId String?
rearTireId String?
setup String
imagePath String?
reviews RideReview[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model RideReview {
id String @id @default(cuid())
buildId String
build BikeBuild @relation(fields: [buildId], references: [id], onDelete: Cascade)
suspFeel Int
frontGrip Int
rearGrip Int
rollSpeed Int
confidence Int
bottomOut Boolean @default(false)
harshHit Boolean @default(false)
wallow Boolean @default(false)
smallBump Boolean @default(false)
notes String?
advice String?
createdAt DateTime @default(now())
}
model RiderPreference {
id String @id @default("default")
harshTendency Int @default(0)
softTendency Int @default(0)
gripPreference Int @default(0)
updatedAt DateTime @updatedAt
}
+24
View File
@@ -0,0 +1,24 @@
import { PrismaClient } from "@prisma/client";
import { frames, forks, shocks, tires } from "../src/lib/catalog";
const prisma = new PrismaClient();
async function main() {
const products = [...frames, ...forks, ...shocks, ...tires];
for (const product of products) {
const { id, category, brand, model, imagePath, sources } = product;
const position = "position" in product ? product.position : null;
await prisma.product.upsert({
where: { id },
update: { category, position, brand, model, imagePath, specs: JSON.stringify(product), setup: JSON.stringify(product), sources: JSON.stringify(sources) },
create: { id, category, position, brand, model, imagePath, specs: JSON.stringify(product), setup: JSON.stringify(product), sources: JSON.stringify(sources) },
});
}
await prisma.riderPreference.upsert({
where: { id: "default" },
update: {},
create: { id: "default" },
});
}
main().finally(async () => prisma.$disconnect());
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { store } from "@/lib/db";
import { selectedProducts } from "@/lib/recommendations";
const schema = z.object({
rider: z.object({ weight: z.number(), discipline: z.string(), level: z.string(), style: z.string() }),
selection: z.object({
name: z.string(),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
}),
setup: z.record(z.unknown()).optional(),
review: z.record(z.unknown()).optional(),
});
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const products = selectedProducts(body.selection);
const prefs = store.preferences();
const prompt = [
"You are a professional mountain bike suspension and tire setup tuner.",
"Combine manufacturer setup ranges, community setup patterns, and the rider's past preferences.",
"Return concise, actionable settings with exact changes where possible.",
`Rider: ${body.rider.weight}kg, ${body.rider.level}, ${body.rider.style}, ${body.rider.discipline}.`,
`Frame: ${products.frame ? `${products.frame.brand} ${products.frame.model}` : "none"}.`,
`Fork: ${products.fork ? `${products.fork.brand} ${products.fork.model}` : "none"}.`,
`Shock: ${products.shock ? `${products.shock.brand} ${products.shock.model}` : "none"}.`,
`Front tire: ${products.frontTire ? `${products.frontTire.brand} ${products.frontTire.model}` : "none"}.`,
`Rear tire: ${products.rearTire ? `${products.rearTire.brand} ${products.rearTire.model}` : "none"}.`,
`Current setup: ${JSON.stringify(body.setup ?? {})}.`,
`Post ride review: ${JSON.stringify(body.review ?? {})}.`,
`Learned preferences: ${JSON.stringify(prefs ?? {})}.`,
].join("\n");
const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
const model = process.env.OLLAMA_MODEL ?? "llama3.1:8b";
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: false,
messages: [
{ role: "system", content: "Use metric units plus PSI/clicks. Structure output as Fork, Shock, Tires, Rationale, Next Ride." },
{ role: "user", content: prompt },
],
}),
});
if (!response.ok) {
return NextResponse.json({ error: `Ollama returned ${response.status}. Is ${model} pulled and Ollama running?` }, { status: 502 });
}
const data = await response.json();
return NextResponse.json({ model, recommendation: data.message?.content ?? data.response ?? "" });
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { store } from "@/lib/db";
import { buildSetupDefaults } from "@/lib/recommendations";
const schema = z.object({
name: z.string().min(1),
rider: z.object({
weight: z.number().min(35).max(160),
discipline: z.string(),
level: z.string(),
style: z.string(),
}),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
setup: z.record(z.unknown()).optional(),
imagePath: z.string().optional(),
});
export async function GET() {
return NextResponse.json(store.listBuilds());
}
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const selection = {
name: body.name,
frameId: body.frameId,
forkId: body.forkId,
shockId: body.shockId,
frontTireId: body.frontTireId,
rearTireId: body.rearTireId,
};
const setup = { ...buildSetupDefaults(selection, body.rider), ...body.setup };
const build = store.createBuild({
name: body.name,
riderWeightKg: body.rider.weight,
discipline: body.rider.discipline,
riderLevel: body.rider.level,
rideStyle: body.rider.style,
frameId: body.frameId,
forkId: body.forkId,
shockId: body.shockId,
frontTireId: body.frontTireId,
rearTireId: body.rearTireId,
setup,
imagePath: body.imagePath,
});
return NextResponse.json(build, { status: 201 });
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { catalog } from "@/lib/catalog";
export async function GET() {
return NextResponse.json(catalog);
}
+39
View File
@@ -0,0 +1,39 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import sharp from "sharp";
import { z } from "zod";
import { canvas, layerPaths, publicPathToFile } from "@/lib/imageLayers";
const schema = z.object({
name: z.string().min(1).default("bike"),
frameId: z.string().optional(),
forkId: z.string().optional(),
shockId: z.string().optional(),
frontTireId: z.string().optional(),
rearTireId: z.string().optional(),
});
export async function POST(request: Request) {
const selection = schema.parse(await request.json());
const layers = layerPaths(selection).map((layer) => ({ input: publicPathToFile(layer), left: 0, top: 0 }));
const outputDir = path.join(process.cwd(), "public", "images", "user");
await mkdir(outputDir, { recursive: true });
const safeName = selection.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "bike";
const fileName = `${safeName}-${Date.now()}.png`;
const output = path.join(outputDir, fileName);
await sharp({
create: {
width: canvas.width,
height: canvas.height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(layers)
.png()
.toFile(output);
return NextResponse.json({ imagePath: `/images/user/${fileName}`, layers: layerPaths(selection) });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { store } from "@/lib/db";
const schema = z.object({
buildId: z.string(),
suspFeel: z.number().min(1).max(10),
frontGrip: z.number().min(1).max(10),
rearGrip: z.number().min(1).max(10),
rollSpeed: z.number().min(1).max(10),
confidence: z.number().min(1).max(10),
bottomOut: z.boolean().default(false),
harshHit: z.boolean().default(false),
wallow: z.boolean().default(false),
smallBump: z.boolean().default(false),
notes: z.string().optional(),
advice: z.string().optional(),
});
export async function POST(request: Request) {
const body = schema.parse(await request.json());
const review = store.createReview(body);
return NextResponse.json(review, { status: 201 });
}
+65
View File
@@ -0,0 +1,65 @@
:root {
--bg: #071009;
--panel: #132016;
--panel2: #19291d;
--line: #2a3f2d;
--line2: #456147;
--text: #d9e7d4;
--muted: #8aa284;
--dim: #60745d;
--green: #79b84a;
--amber: #c9913d;
--red: #d16a55;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.app { max-width: 1180px; margin: 0 auto; padding: 24px 16px 80px; }
.header { display: flex; justify-content: space-between; gap: 16px; align-items: flex-end; border-bottom: 1px solid var(--line); padding-bottom: 16px; margin-bottom: 18px; }
.brand { font-size: clamp(30px, 7vw, 64px); line-height: .9; letter-spacing: .08em; text-transform: uppercase; font-weight: 900; }
.subtitle { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 12px; }
.tabs { display: flex; gap: 4px; overflow-x: auto; background: #0b160e; border: 1px solid var(--line); padding: 4px; margin-bottom: 18px; }
.tab { flex: 1; min-width: 110px; border: 0; background: transparent; color: var(--muted); padding: 10px; text-transform: uppercase; letter-spacing: .08em; font-weight: 800; }
.tab.active { background: var(--green); color: #061007; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; }
.profile { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; background: var(--panel); border: 1px solid var(--line); padding: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { color: var(--muted); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; font-weight: 800; }
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--line); background: #0b160e; color: var(--text); padding: 10px; border-radius: 4px; }
.field textarea { min-height: 100px; resize: vertical; }
.hint { color: var(--dim); font-size: 12px; }
.section-title { margin: 24px 0 10px; padding-bottom: 8px; border-bottom: 1px solid var(--line); color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 12px; font-weight: 900; }
.card { border: 1px solid var(--line); background: var(--panel); padding: 12px; border-radius: 6px; text-align: left; color: inherit; min-height: 132px; }
.card:hover, .card.selected { border-color: var(--green); background: var(--panel2); }
.card .brand-small { color: var(--muted); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; }
.card .model { font-size: 22px; font-weight: 900; margin: 4px 0; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.tag { border: 1px solid var(--line2); color: var(--muted); padding: 3px 7px; font-size: 11px; border-radius: 999px; }
.tag.rec { border-color: var(--green); color: var(--green); }
.viewer { position: relative; border: 1px solid var(--line); background: radial-gradient(circle at 50% 60%, #182719, #071009 70%); overflow: hidden; aspect-ratio: 2400 / 1464; margin-bottom: 16px; }
.viewer img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.viewer-empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); text-transform: uppercase; letter-spacing: .12em; font-weight: 800; font-size: 12px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.row > .field { flex: 1; min-width: 240px; }
.btn { border: 1px solid var(--line2); background: #101d13; color: var(--text); padding: 10px 14px; border-radius: 4px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; }
.btn.primary { background: var(--green); border-color: var(--green); color: #061007; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.stat { background: var(--panel); border: 1px solid var(--line); padding: 12px; border-radius: 6px; }
.stat b { display: block; font-size: 24px; }
.notice, .output { border-left: 3px solid var(--green); background: #0b160e; padding: 12px 14px; color: var(--muted); line-height: 1.55; }
.output { color: var(--text); white-space: pre-wrap; margin-top: 12px; }
.range-row { display: grid; grid-template-columns: 150px 1fr 40px; gap: 10px; align-items: center; margin: 10px 0; }
.build-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }
@media (max-width: 620px) {
.header { align-items: start; flex-direction: column; }
.range-row { grid-template-columns: 1fr; }
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Loam MTB Builder",
description: "Build, tune, and review MTB setups.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import BikeBuilderApp from "@/components/BikeBuilderApp";
export default function Home() {
return <BikeBuilderApp />;
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { catalog } from "@/lib/catalog";
import { buildSetupDefaults, disciplines, isRecommended, riderLevels, rideStyles, selectedProducts } from "@/lib/recommendations";
import { layerPaths } from "@/lib/layers";
import type { BuildSelection, BuildSetup, Product, RiderProfile } from "@/lib/types";
type Tab = "build" | "setup" | "review" | "garage" | "ai";
const emptySelection: BuildSelection = { name: "" };
const emptyRider: RiderProfile = { weight: 78, discipline: "Trail", level: "Intermediate", style: "Mixed" };
export default function BikeBuilderApp() {
const [tab, setTab] = useState<Tab>("build");
const [rider, setRider] = useState<RiderProfile>(emptyRider);
const [selection, setSelection] = useState<BuildSelection>(emptySelection);
const defaults = useMemo(() => buildSetupDefaults(selection, rider), [selection, rider]);
const [setup, setSetup] = useState<Partial<BuildSetup>>({});
const [builds, setBuilds] = useState<any[]>([]);
const [review, setReview] = useState({ buildId: "", suspFeel: 5, frontGrip: 7, rearGrip: 7, rollSpeed: 6, confidence: 7, bottomOut: false, harshHit: false, wallow: false, smallBump: false, notes: "" });
const [aiOut, setAiOut] = useState("");
const [loading, setLoading] = useState("");
const selected = selectedProducts(selection);
const mergedSetup = { ...defaults, ...setup };
useEffect(() => {
fetch("/api/builds")
.then((response) => response.json())
.then((data) => setBuilds(Array.isArray(data) ? data : []))
.catch(() => setBuilds([]));
}, []);
function choose(key: keyof BuildSelection, id: string) {
setSelection((current) => ({ ...current, [key]: current[key] === id ? undefined : id }));
}
async function saveBuild() {
if (!selection.name.trim()) return;
setLoading("Saving build");
const image = await fetch("/api/images/compose", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(selection) }).then((r) => r.json());
const saved = await fetch("/api/builds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...selection, rider, setup: mergedSetup, imagePath: image.imagePath }),
}).then((r) => r.json());
setBuilds((items) => [saved, ...items]);
setLoading("");
setTab("garage");
}
async function getAiRecommendation(extraReview?: typeof review) {
setLoading("Querying Ollama");
setAiOut("");
const result = await fetch("/api/ai/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rider, selection, setup: mergedSetup, review: extraReview }),
}).then((r) => r.json());
setAiOut(result.recommendation ?? result.error ?? "No recommendation returned.");
setLoading("");
}
async function saveReview() {
if (!review.buildId) return;
setLoading("Saving review");
await fetch("/api/reviews", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(review) });
await getAiRecommendation(review);
}
return (
<main className="app">
<header className="header">
<div>
<div className="brand">Loam</div>
<div className="subtitle">MTB Build & Tune</div>
</div>
<div className="subtitle">Next.js web + Node API for Android reuse</div>
</header>
<nav className="tabs">
{(["build", "setup", "review", "garage", "ai"] as Tab[]).map((item) => (
<button key={item} className={`tab ${tab === item ? "active" : ""}`} onClick={() => setTab(item)}>{item}</button>
))}
</nav>
{tab === "build" && (
<>
<RiderProfileForm rider={rider} setRider={setRider} />
<BikeLayerViewer selection={selection} />
<div className="row">
<div className="field">
<label>Build name</label>
<input value={selection.name} onChange={(e) => setSelection((current) => ({ ...current, name: e.target.value }))} placeholder="Scouty wet trail setup" />
</div>
<button className="btn primary" onClick={saveBuild} disabled={!selection.name || Boolean(loading)}>{loading || "Save build"}</button>
</div>
<ProductSection title="Frame" products={catalog.frames} selectedId={selection.frameId} onPick={(id) => choose("frameId", id)} rider={rider} />
<ProductSection title="Fork" products={catalog.forks} selectedId={selection.forkId} onPick={(id) => choose("forkId", id)} rider={rider} />
<ProductSection title="Rear shock" products={catalog.shocks} selectedId={selection.shockId} onPick={(id) => choose("shockId", id)} rider={rider} />
<ProductSection title="Front tire" products={catalog.tires.filter((t) => t.position === "front")} selectedId={selection.frontTireId} onPick={(id) => choose("frontTireId", id)} rider={rider} />
<ProductSection title="Rear tire" products={catalog.tires.filter((t) => t.position === "rear")} selectedId={selection.rearTireId} onPick={(id) => choose("rearTireId", id)} rider={rider} />
</>
)}
{tab === "setup" && (
<>
<div className="notice">Recommended values are shown as placeholders and come from the shared setup database plus rider profile rules. Edit values before saving if your real setup differs.</div>
<div className="section-title">Suspension and tires</div>
<div className="setup-grid">
{Object.entries(mergedSetup).filter(([key]) => key !== "notes").map(([key, value]) => (
<div className="field" key={key}>
<label>{labelFor(key)}</label>
<input value={(setup as any)[key] ?? ""} placeholder={String(value ?? "")} onChange={(e) => setSetup((current) => ({ ...current, [key]: numericOrText(e.target.value) }))} />
</div>
))}
</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Setup notes</label>
<textarea value={setup.notes ?? ""} onChange={(e) => setSetup((current) => ({ ...current, notes: e.target.value }))} placeholder="Trail conditions, bracketing notes, clicks changed..." />
</div>
</>
)}
{tab === "review" && (
<>
<div className="notice">Post-ride reviews are saved to the backend. The preference counters bias future Ollama recommendations.</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Build</label>
<select value={review.buildId} onChange={(e) => setReview((current) => ({ ...current, buildId: e.target.value }))}>
<option value="">Choose saved build</option>
{builds.map((build) => <option key={build.id} value={build.id}>{build.name}</option>)}
</select>
</div>
{["suspFeel", "frontGrip", "rearGrip", "rollSpeed", "confidence"].map((key) => (
<Range key={key} label={labelFor(key)} value={(review as any)[key]} onChange={(value) => setReview((current) => ({ ...current, [key]: value }))} />
))}
<div className="grid">
{["bottomOut", "harshHit", "wallow", "smallBump"].map((key) => (
<label className="card" key={key}>
<input type="checkbox" checked={(review as any)[key]} onChange={(e) => setReview((current) => ({ ...current, [key]: e.target.checked }))} /> {labelFor(key)}
</label>
))}
</div>
<div className="field" style={{ marginTop: 12 }}>
<label>Ride notes</label>
<textarea value={review.notes} onChange={(e) => setReview((current) => ({ ...current, notes: e.target.value }))} />
</div>
<button className="btn primary" style={{ marginTop: 12 }} onClick={saveReview} disabled={!review.buildId || Boolean(loading)}>{loading || "Save review and recommend changes"}</button>
{aiOut && <div className="output">{aiOut}</div>}
</>
)}
{tab === "garage" && (
<div className="build-list">
{builds.length === 0 && <div className="notice">No builds saved in this browser session yet. The API persists builds in SQLite once the database is initialized.</div>}
{builds.map((build) => (
<button className="card" key={build.id} onClick={() => setSelection({ name: build.name, frameId: build.frameId, forkId: build.forkId, shockId: build.shockId, frontTireId: build.frontTireId, rearTireId: build.rearTireId })}>
<div className="model">{build.name}</div>
<div className="brand-small">{build.discipline} / {build.riderWeightKg}kg / {build.riderLevel}</div>
{build.imagePath && <img src={build.imagePath} alt="" style={{ width: "100%", marginTop: 8 }} />}
</button>
))}
</div>
)}
{tab === "ai" && (
<>
<div className="notice">Recommended local model: <b>llama3.1:8b</b>. It is a good first choice for structured tuning advice on normal developer hardware. Use <b>qwen2.5:7b</b> if you prefer stronger technical reasoning, or a smaller model only if latency matters more than advice quality.</div>
<div className="section-title">Current build</div>
<div className="stat">
<b>{selection.name || "Unsaved build"}</b>
<p>{[selected.frame, selected.fork, selected.shock, selected.frontTire, selected.rearTire].filter(Boolean).map((item) => `${item!.brand} ${item!.model}`).join(" / ") || "No components selected"}</p>
</div>
<button className="btn primary" style={{ marginTop: 12 }} onClick={() => getAiRecommendation()} disabled={Boolean(loading)}>{loading || "Generate Ollama recommendation"}</button>
{aiOut && <div className="output">{aiOut}</div>}
</>
)}
</main>
);
}
function RiderProfileForm({ rider, setRider }: { rider: RiderProfile; setRider: (rider: RiderProfile) => void }) {
return (
<div className="profile">
<div className="field"><label>Weight kg</label><input type="number" value={rider.weight} onChange={(e) => setRider({ ...rider, weight: Number(e.target.value) })} /></div>
<div className="field"><label>Discipline</label><select value={rider.discipline} onChange={(e) => setRider({ ...rider, discipline: e.target.value })}>{disciplines.map((x) => <option key={x}>{x}</option>)}</select></div>
<div className="field"><label>Level</label><select value={rider.level} onChange={(e) => setRider({ ...rider, level: e.target.value })}>{riderLevels.map((x) => <option key={x}>{x}</option>)}</select></div>
<div className="field"><label>Style</label><select value={rider.style} onChange={(e) => setRider({ ...rider, style: e.target.value })}>{rideStyles.map((x) => <option key={x}>{x}</option>)}</select></div>
</div>
);
}
function BikeLayerViewer({ selection }: { selection: BuildSelection }) {
const layers = layerPaths(selection);
return (
<div className="viewer">
{layers.length === 0 && <div className="viewer-empty">Select components to build your bike</div>}
{layers.map((layer) => <img key={layer} src={layer} alt="" />)}
</div>
);
}
function ProductSection({ title, products, selectedId, onPick, rider }: { title: string; products: Product[]; selectedId?: string; onPick: (id: string) => void; rider: RiderProfile }) {
return (
<>
<div className="section-title">{title}</div>
<div className="grid">
{products.map((product) => (
<button key={product.id} className={`card ${selectedId === product.id ? "selected" : ""}`} onClick={() => onPick(product.id)}>
<div className="brand-small">{product.brand}</div>
<div className="model">{product.model}</div>
<div className="hint">{"description" in product ? product.description : product.notes}</div>
<div className="tags">
{isRecommended(product.id, rider) && <span className="tag rec">recommended</span>}
{"travel" in product && <span className="tag">{product.travel}mm</span>}
{"travels" in product && <span className="tag">{product.travels.join("/")}mm</span>}
{"type" in product && <span className="tag">{product.type}</span>}
{"width" in product && <span className="tag">{product.width}&quot;</span>}
</div>
</button>
))}
</div>
</>
);
}
function Range({ label, value, onChange }: { label: string; value: number; onChange: (value: number) => void }) {
return (
<div className="range-row">
<span>{label}</span>
<input type="range" min={1} max={10} value={value} onChange={(e) => onChange(Number(e.target.value))} />
<b>{value}</b>
</div>
);
}
function labelFor(key: string) {
return key.replace(/([A-Z])/g, " $1").replace(/^./, (char) => char.toUpperCase()).replace("Psi", "PSI").replace("Lsc", "LSC").replace("Hsc", "HSC");
}
function numericOrText(value: string) {
if (value.trim() === "") return undefined;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : value;
}
+53
View File
@@ -0,0 +1,53 @@
import type { FrameProduct, ForkProduct, ShockProduct, TireProduct } from "./types";
const manufacturer = (title: string, url = "") => ({ title, url, kind: "manufacturer" as const });
const community = (title: string, url = "") => ({ title, url, kind: "community" as const });
const placeholder = { title: "Needs verified setup research before production use", url: "", kind: "placeholder" as const };
export const frames: FrameProduct[] = [
{ id: "transition-sentinel", category: "frame", brand: "Transition", model: "Sentinel", travel: 140, headAngle: 64, geometry: "enduro", bestFor: ["enduro", "trail"], description: "Aggressive trail and light enduro frame.", sources: [manufacturer("Transition Sentinel geometry"), placeholder] },
{ id: "transition-scout", category: "frame", brand: "Transition", model: "Scout", travel: 130, headAngle: 65.5, geometry: "trail", bestFor: ["trail", "xc"], description: "Playful short-travel trail frame.", imagePath: "/images/frame/transition/scout.png", sources: [manufacturer("Transition Scout geometry"), community("Existing BSU image asset")] },
{ id: "norco-sight-vlt", category: "frame", brand: "Norco", model: "Sight VLT", travel: 140, headAngle: 64, geometry: "enduro", bestFor: ["enduro", "trail"], description: "Full-power e-MTB enduro platform.", sources: [manufacturer("Norco Sight VLT geometry"), placeholder] },
{ id: "canyon-spectral", category: "frame", brand: "Canyon", model: "Spectral", travel: 140, headAngle: 64.5, geometry: "trail", bestFor: ["trail", "enduro"], description: "Balanced all-mountain platform.", sources: [manufacturer("Canyon Spectral geometry"), placeholder] },
{ id: "last-coal", category: "frame", brand: "Last", model: "Coal", travel: 150, headAngle: 63.5, geometry: "enduro", bestFor: ["enduro"], description: "Long, slack enduro frame.", sources: [manufacturer("LAST Coal geometry"), placeholder] },
{ id: "evil-the-calling", category: "frame", brand: "Evil", model: "The Calling", travel: 140, headAngle: 65, geometry: "trail", bestFor: ["trail", "enduro"], description: "Responsive technical trail frame.", sources: [manufacturer("Evil The Calling geometry"), placeholder] },
];
export const forks: ForkProduct[] = [
{ id: "manitou-mezzer", category: "fork", brand: "Manitou", model: "Mezzer", travels: [140, 150, 160], damper: "MC2 / IRT", psi: { lt: [60, 72], md: [70, 84], hv: [82, 100] }, sag: { xc: 20, trail: 25, enduro: 27 }, rebound: { lt: 14, md: 11, hv: 9 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Very tunable, supple air spring with strong mid-stroke support.", imagePath: "/images/fork/manitou/mezzer.png", sources: [manufacturer("Manitou Mezzer setup guide"), community("Pinkbike and MTBR Mezzer setup discussions")] },
{ id: "fox-38", category: "fork", brand: "Fox", model: "38 Factory", travels: [160, 170, 180], damper: "GRIP2", psi: { lt: [65, 78], md: [76, 92], hv: [88, 110] }, sag: { xc: 20, trail: 25, enduro: 28 }, rebound: { lt: 13, md: 11, hv: 8 }, lsc: { lt: 6, md: 8, hv: 10 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Stiff enduro chassis for hard charging.", sources: [manufacturer("Fox 38 tuning guide"), placeholder] },
{ id: "fox-36", category: "fork", brand: "Fox", model: "36 Factory", travels: [140, 150, 160], damper: "GRIP2", psi: { lt: [60, 74], md: [70, 86], hv: [82, 102] }, sag: { xc: 20, trail: 25, enduro: 27 }, rebound: { lt: 13, md: 11, hv: 9 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 2, md: 3, hv: 4 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "All-mountain default with good stiffness-to-weight balance.", sources: [manufacturer("Fox 36 tuning guide"), placeholder] },
{ id: "fox-34", category: "fork", brand: "Fox", model: "34 Factory", travels: [120, 130, 140], damper: "GRIP2", psi: { lt: [55, 68], md: [64, 80], hv: [76, 94] }, sag: { xc: 20, trail: 22, enduro: 25 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 2, md: 3, hv: 4 }, tokens: { lt: 0, md: 0, hv: 1 }, notes: "Light trail and downcountry option.", sources: [manufacturer("Fox 34 tuning guide"), placeholder] },
{ id: "rockshox-zeb", category: "fork", brand: "RockShox", model: "ZEB Ultimate", travels: [150, 160, 170, 180], damper: "Charger 3 / DebonAir+", psi: { lt: [62, 76], md: [72, 88], hv: [84, 106] }, sag: { xc: 20, trail: 25, enduro: 28 }, rebound: { lt: 14, md: 12, hv: 10 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Plush big-bike fork for enduro terrain.", imagePath: "/images/fork/rockshox/zeb.png", sources: [manufacturer("RockShox ZEB product page", "https://www.sram.com/en/rockshox/models/fs-zeb-ult-b1"), placeholder] },
{ id: "rockshox-lyrik", category: "fork", brand: "RockShox", model: "Lyrik Ultimate", travels: [140, 150, 160], damper: "Charger 3 / DebonAir+", psi: { lt: [58, 72], md: [68, 83], hv: [80, 100] }, sag: { xc: 20, trail: 25, enduro: 28 }, rebound: { lt: 14, md: 12, hv: 10 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 2, md: 3, hv: 4 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Strong trail/enduro all-rounder.", imagePath: "/images/fork/rockshox/lyrik.png", sources: [manufacturer("RockShox Lyrik product page", "https://www.sram.com/en/rockshox/models/fs-lyrk-ult-e1"), placeholder] },
{ id: "rockshox-pike", category: "fork", brand: "RockShox", model: "Pike Ultimate", travels: [120, 130, 140], damper: "Charger 3 / DebonAir+", psi: { lt: [55, 68], md: [64, 78], hv: [76, 93] }, sag: { xc: 20, trail: 22, enduro: 25 }, rebound: { lt: 13, md: 11, hv: 9 }, lsc: { lt: 3, md: 5, hv: 7 }, hsc: { lt: 2, md: 3, hv: 3 }, tokens: { lt: 0, md: 0, hv: 1 }, notes: "Light and precise trail fork.", imagePath: "/images/fork/rockshox/pike.png", sources: [manufacturer("RockShox Pike product page", "https://www.sram.com/en/rockshox/models/fs-pike-ult-c2"), placeholder] },
];
export const shocks: ShockProduct[] = [
{ id: "fox-x2", category: "shock", brand: "Fox", model: "X2 Factory", type: "air", psi: { lt: [140, 158], md: [158, 185], hv: [185, 212] }, sag: { xc: 25, trail: 28, enduro: 31 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 5, md: 7, hv: 9 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Highly adjustable air shock for technical speed.", sources: [manufacturer("Fox X2 tuning guide"), placeholder] },
{ id: "fox-float-x", category: "shock", brand: "Fox", model: "Float X Factory", type: "air", psi: { lt: [132, 152], md: [152, 178], hv: [178, 205] }, sag: { xc: 25, trail: 28, enduro: 30 }, rebound: { lt: 11, md: 9, hv: 7 }, lsc: { lt: 5, md: 7, hv: 9 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Simpler trail/enduro air shock.", sources: [manufacturer("Fox Float X tuning guide"), placeholder] },
{ id: "rockshox-vivid-air-ultimate", category: "shock", brand: "RockShox", model: "Vivid Air Ultimate", type: "air", psi: { lt: [128, 150], md: [150, 178], hv: [178, 208] }, sag: { xc: 25, trail: 28, enduro: 32 }, rebound: { lt: 13, md: 11, hv: 9 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Supple high-volume air shock.", imagePath: "/images/shock/rockshox/vivid_air_ultimate.png", sources: [manufacturer("RockShox Vivid setup guide"), community("Existing BSU image asset")] },
{ id: "rockshox-super-deluxe-ultimate", category: "shock", brand: "RockShox", model: "Super Deluxe Ultimate", type: "air", psi: { lt: [135, 158], md: [158, 184], hv: [184, 212] }, sag: { xc: 25, trail: 28, enduro: 31 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 3, md: 5, hv: 7 }, hsc: { lt: 3, md: 4, hv: 5 }, tokens: { lt: 0, md: 1, hv: 2 }, notes: "Reliable trail/enduro OEM workhorse.", imagePath: "/images/shock/rockshox/super_deluxe_ultimate.png", sources: [manufacturer("RockShox Super Deluxe product page", "https://www.sram.com/en/rockshox/models/rs-sdlx-ult-d1"), placeholder] },
{ id: "rockshox-super-deluxe-coil-ultimate", category: "shock", brand: "RockShox", model: "Super Deluxe Coil Ultimate", type: "coil", sag: { xc: 30, trail: 33, enduro: 36 }, rebound: { lt: 12, md: 10, hv: 8 }, lsc: { lt: 4, md: 6, hv: 8 }, hsc: { lt: 3, md: 4, hv: 5 }, spring: { lt: "350-400 lb/in", md: "400-500 lb/in", hv: "500-600 lb/in" }, notes: "Coil feel and heat consistency for descending-focused builds.", imagePath: "/images/shock/rockshox/super_deluxe_coil_ultimate.png", sources: [manufacturer("RockShox Super Deluxe Coil product page", "https://www.sram.com/en/rockshox/models/rs-sdlc-ult-b1"), placeholder] },
];
const tireSources = [manufacturer("Brand tire pressure guidance"), community("Common community starting pressures")];
const maxxisSources = [manufacturer("Maxxis product images", "https://www.maxxis.com/catalog/tire/"), manufacturer("Brand tire pressure guidance"), community("Common community starting pressures")];
export const tires: TireProduct[] = [
{ id: "maxxis-assegai-front", category: "tire", position: "front", brand: "Maxxis", model: "Assegai", width: 2.5, grip: 10, rolling: 3, bestFor: ["enduro", "wet"], psi: { lt: [17, 22], md: [21, 26], hv: [25, 30] }, notes: "Maximum front grip.", imagePath: "/images/tire/front/maxxis/assegai.png", sources: maxxisSources },
{ id: "maxxis-dissector-front", category: "tire", position: "front", brand: "Maxxis", model: "Dissector", width: 2.4, grip: 7, rolling: 7, bestFor: ["trail", "xc", "dry"], psi: { lt: [20, 24], md: [23, 28], hv: [27, 32] }, notes: "Fast dry front option.", imagePath: "/images/tire/front/maxxis/dissector.png", sources: maxxisSources },
{ id: "maxxis-minion-front", category: "tire", position: "front", brand: "Maxxis", model: "Minion DHF", width: 2.5, grip: 9, rolling: 5, bestFor: ["enduro", "trail"], psi: { lt: [18, 23], md: [22, 27], hv: [25, 30] }, notes: "Predictable benchmark front.", imagePath: "/images/tire/front/maxxis/minion.png", sources: maxxisSources },
{ id: "schwalbe-magic-mary-front", category: "tire", position: "front", brand: "Schwalbe", model: "Magic Mary", width: 2.4, grip: 10, rolling: 3, bestFor: ["enduro", "wet"], psi: { lt: [16, 21], md: [20, 25], hv: [24, 29] }, notes: "Wet and soft-condition front.", sources: tireSources },
{ id: "schwalbe-albert-radial-front", category: "tire", position: "front", brand: "Schwalbe", model: "Albert Radial", width: 2.35, grip: 8, rolling: 6, bestFor: ["trail", "enduro"], psi: { lt: [19, 24], md: [22, 27], hv: [26, 30] }, notes: "Supple radial casing.", imagePath: "/images/tire/front/schwalbe/albert.png", sources: tireSources },
{ id: "specialized-eliminator-front", category: "tire", position: "front", brand: "Specialized", model: "Eliminator", width: 2.3, grip: 7, rolling: 7, bestFor: ["trail", "xc"], psi: { lt: [21, 26], md: [24, 29], hv: [28, 33] }, notes: "Efficient front option.", sources: tireSources },
{ id: "specialized-butcher-front", category: "tire", position: "front", brand: "Specialized", model: "Butcher", width: 2.3, grip: 8, rolling: 6, bestFor: ["enduro", "trail"], psi: { lt: [20, 25], md: [23, 28], hv: [26, 31] }, notes: "Aggressive daily front.", sources: tireSources },
{ id: "maxxis-assegai-rear", category: "tire", position: "rear", brand: "Maxxis", model: "Assegai", width: 2.5, grip: 9, rolling: 4, bestFor: ["enduro"], psi: { lt: [22, 27], md: [25, 30], hv: [29, 34] }, notes: "Very grippy but slow rear.", imagePath: "/images/tire/rear/maxxis/assegai.png", sources: maxxisSources },
{ id: "maxxis-dissector-rear", category: "tire", position: "rear", brand: "Maxxis", model: "Dissector", width: 2.4, grip: 7, rolling: 8, bestFor: ["trail", "xc", "dry"], psi: { lt: [22, 27], md: [26, 31], hv: [30, 35] }, notes: "Fast rolling rear.", imagePath: "/images/tire/rear/maxxis/dissector.png", sources: maxxisSources },
{ id: "maxxis-minion-rear", category: "tire", position: "rear", brand: "Maxxis", model: "Minion DHR II", width: 2.4, grip: 8, rolling: 6, bestFor: ["enduro", "trail"], psi: { lt: [21, 26], md: [24, 29], hv: [28, 33] }, notes: "Balanced rear traction.", imagePath: "/images/tire/rear/maxxis/minion.png", sources: maxxisSources },
{ id: "schwalbe-magic-mary-rear", category: "tire", position: "rear", brand: "Schwalbe", model: "Magic Mary", width: 2.4, grip: 9, rolling: 3, bestFor: ["enduro", "wet"], psi: { lt: [20, 25], md: [23, 28], hv: [27, 32] }, notes: "Wet rear traction.", sources: tireSources },
{ id: "schwalbe-albert-radial-rear", category: "tire", position: "rear", brand: "Schwalbe", model: "Albert Radial", width: 2.35, grip: 7, rolling: 7, bestFor: ["trail", "enduro"], psi: { lt: [21, 26], md: [24, 29], hv: [27, 32] }, notes: "Forgiving all-conditions rear.", imagePath: "/images/tire/rear/schwalbe/albert.png", sources: tireSources },
{ id: "specialized-eliminator-rear", category: "tire", position: "rear", brand: "Specialized", model: "Eliminator", width: 2.3, grip: 7, rolling: 8, bestFor: ["trail", "xc"], psi: { lt: [23, 28], md: [26, 31], hv: [30, 35] }, notes: "Fast trail rear.", sources: tireSources },
{ id: "specialized-butcher-rear", category: "tire", position: "rear", brand: "Specialized", model: "Butcher", width: 2.3, grip: 8, rolling: 7, bestFor: ["enduro", "trail"], psi: { lt: [21, 26], md: [24, 29], hv: [27, 33] }, notes: "Balanced rear option.", sources: tireSources },
];
export const catalog = { frames, forks, shocks, tires };
+58
View File
@@ -0,0 +1,58 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
type Database = {
builds: any[];
reviews: any[];
};
const dbPath = path.join(process.cwd(), "data", "bike-app.json");
function readDb(): Database {
mkdirSync(path.dirname(dbPath), { recursive: true });
try {
return JSON.parse(readFileSync(dbPath, "utf8")) as Database;
} catch {
return { builds: [], reviews: [] };
}
}
function writeDb(data: Database) {
mkdirSync(path.dirname(dbPath), { recursive: true });
writeFileSync(dbPath, JSON.stringify(data, null, 2));
}
export const store = {
listBuilds() {
return readDb().builds.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
},
createBuild(input: any) {
const data = readDb();
const build = {
id: crypto.randomUUID(),
...input,
createdAt: new Date().toISOString(),
};
data.builds.unshift(build);
writeDb(data);
return build;
},
createReview(input: any) {
const data = readDb();
const review = {
id: crypto.randomUUID(),
...input,
createdAt: new Date().toISOString(),
};
data.reviews.unshift(review);
writeDb(data);
return review;
},
preferences() {
const reviews = readDb().reviews;
return {
harshTendency: reviews.filter((review) => review.suspFeel >= 7).length,
softTendency: reviews.filter((review) => review.suspFeel <= 3).length,
};
},
};
+7
View File
@@ -0,0 +1,7 @@
import path from "node:path";
export { canvas, layerPaths } from "./layers";
export function publicPathToFile(publicPath: string) {
const clean = publicPath.replace(/^\/+/, "");
return path.join(process.cwd(), "public", clean);
}
+17
View File
@@ -0,0 +1,17 @@
import { selectedProducts } from "./recommendations";
import type { BuildSelection } from "./types";
export const canvas = { width: 2400, height: 1464 };
export function layerPaths(selection: BuildSelection) {
const selected = selectedProducts(selection);
return [
"/images/common/cockpit.png",
"/images/common/seatpost.png",
selected.rearTire?.imagePath,
selected.frontTire?.imagePath,
selected.shock?.imagePath,
selected.fork?.imagePath,
selected.frame?.imagePath,
].filter(Boolean) as string[];
}
+80
View File
@@ -0,0 +1,80 @@
import { frames, forks, shocks, tires } from "./catalog";
import type { BuildSelection, BuildSetup, DisciplineCategory, RiderProfile, WeightCategory } from "./types";
export const disciplines = ["XC / Marathon", "Trail", "Enduro", "DH / Park"];
export const riderLevels = ["Beginner", "Intermediate", "Advanced", "Expert / Racer"];
export const rideStyles = ["Smooth & Pedally", "Aggressive / Attack", "Tech-focused", "Flow & Jump", "Mixed"];
export function weightCategory(kg: number): WeightCategory {
if (kg < 65) return "lt";
if (kg < 85) return "md";
return "hv";
}
export function disciplineCategory(discipline: string): DisciplineCategory {
const text = discipline.toLowerCase();
if (text.includes("xc")) return "xc";
if (text.includes("enduro") || text.includes("dh")) return "enduro";
return "trail";
}
export function isRecommended(productId: string, rider: RiderProfile) {
const dc = disciplineCategory(rider.discipline);
const frame = frames.find((item) => item.id === productId);
if (frame) return frame.bestFor.includes(dc) || frame.bestFor.includes("trail");
const fork = forks.find((item) => item.id === productId);
if (fork) {
const minTravel = dc === "enduro" ? 150 : dc === "trail" ? 130 : 110;
const maxTravel = dc === "xc" ? 140 : 200;
return fork.travels.some((travel) => travel >= minTravel && travel <= maxTravel);
}
const shock = shocks.find((item) => item.id === productId);
if (shock) return !(dc === "xc" && shock.type === "coil");
const tire = tires.find((item) => item.id === productId);
if (tire) return tire.bestFor.includes(dc) || (tire.position === "front" && tire.bestFor.includes("trail"));
return false;
}
export function buildSetupDefaults(selection: BuildSelection, rider: RiderProfile): BuildSetup {
const wc = weightCategory(rider.weight);
const dc = disciplineCategory(rider.discipline);
const levelMod = rider.level === "Expert / Racer" ? 2 : rider.level === "Advanced" ? 1 : 0;
const fork = forks.find((item) => item.id === selection.forkId);
const shock = shocks.find((item) => item.id === selection.shockId);
const frontTire = tires.find((item) => item.id === selection.frontTireId);
const rearTire = tires.find((item) => item.id === selection.rearTireId);
const midpoint = (range?: [number, number], mod = 0) => range ? Math.round((range[0] + range[1]) / 2 + mod) : undefined;
return {
forkPsi: midpoint(fork?.psi[wc], levelMod * 2),
forkSag: fork?.sag[dc] ?? 25,
forkRebound: fork ? Math.max(0, fork.rebound[wc] - levelMod) : undefined,
forkLsc: fork ? fork.lsc[wc] + levelMod : undefined,
forkHsc: fork?.hsc ? fork.hsc[wc] + levelMod : undefined,
forkTokens: fork?.tokens[wc],
shockPsi: shock?.type === "air" ? midpoint(shock.psi?.[wc], levelMod * 3) : undefined,
shockSpring: shock?.type === "coil" ? shock.spring?.[wc] : undefined,
shockSag: shock?.sag[dc] ?? 28,
shockRebound: shock ? Math.max(0, shock.rebound[wc] - levelMod) : undefined,
shockLsc: shock ? shock.lsc[wc] + levelMod : undefined,
shockHsc: shock?.hsc ? shock.hsc[wc] + levelMod : undefined,
shockTokens: shock?.tokens?.[wc],
frontTirePsi: midpoint(frontTire?.psi[wc]),
rearTirePsi: midpoint(rearTire?.psi[wc]),
};
}
export function selectedProducts(selection: BuildSelection) {
return {
frame: frames.find((item) => item.id === selection.frameId),
fork: forks.find((item) => item.id === selection.forkId),
shock: shocks.find((item) => item.id === selection.shockId),
frontTire: tires.find((item) => item.id === selection.frontTireId),
rearTire: tires.find((item) => item.id === selection.rearTireId),
};
}
+115
View File
@@ -0,0 +1,115 @@
export type WeightCategory = "lt" | "md" | "hv";
export type DisciplineCategory = "xc" | "trail" | "enduro";
export type RiderProfile = {
weight: number;
discipline: string;
level: string;
style: string;
};
export type SourceRef = {
title: string;
url: string;
kind: "manufacturer" | "community" | "placeholder";
};
type SetupRange = Record<WeightCategory, [number, number]>;
type SetupByDiscipline = Record<DisciplineCategory, number>;
type SetupByWeight = Record<WeightCategory, number>;
export type FrameProduct = {
id: string;
category: "frame";
brand: string;
model: string;
travel: number;
headAngle: number;
geometry: DisciplineCategory;
bestFor: DisciplineCategory[];
description: string;
imagePath?: string;
sources: SourceRef[];
};
export type ForkProduct = {
id: string;
category: "fork";
brand: string;
model: string;
travels: number[];
damper: string;
psi: SetupRange;
sag: SetupByDiscipline;
rebound: SetupByWeight;
lsc: SetupByWeight;
hsc?: SetupByWeight;
tokens: SetupByWeight;
notes: string;
imagePath?: string;
sources: SourceRef[];
};
export type ShockProduct = {
id: string;
category: "shock";
brand: string;
model: string;
type: "air" | "coil";
psi?: SetupRange;
sag: SetupByDiscipline;
rebound: SetupByWeight;
lsc: SetupByWeight;
hsc?: SetupByWeight;
tokens?: SetupByWeight;
spring?: Record<WeightCategory, string>;
notes: string;
imagePath?: string;
sources: SourceRef[];
};
export type TireProduct = {
id: string;
category: "tire";
position: "front" | "rear";
brand: string;
model: string;
width: number;
grip: number;
rolling: number;
bestFor: string[];
psi: SetupRange;
notes: string;
imagePath?: string;
sources: SourceRef[];
};
export type Product = FrameProduct | ForkProduct | ShockProduct | TireProduct;
export type BuildSelection = {
name: string;
frameId?: string;
forkId?: string;
shockId?: string;
frontTireId?: string;
rearTireId?: string;
};
export type BuildSetup = {
forkPsi?: number;
forkSag: number;
forkRebound?: number;
forkLsc?: number;
forkHsc?: number;
forkTokens?: number;
shockPsi?: number;
shockSpring?: string;
shockSag: number;
shockRebound?: number;
shockLsc?: number;
shockHsc?: number;
shockTokens?: number;
frontTirePsi?: number;
rearTirePsi?: number;
notes?: string;
};
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
"prisma/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"BSU"
]
}