Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/compare/[bikeAId]/[bikeBId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { redirect } from 'next/navigation'

interface LegacyComparePageProps {
params: Promise<{ bikeAId: string; bikeBId: string }>
}

/**
* Legacy path-based compare URL. Redirects to the new query-param form so
* bookmarks and in-flight links keep working.
*/
export default async function LegacyComparePage({ params }: LegacyComparePageProps) {
const { bikeAId, bikeBId } = await params
redirect(`/compare?bikes=${bikeAId},${bikeBId}`)
}
73 changes: 73 additions & 0 deletions app/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getManyBikes, type Bike } from '@/utils/getBike'
import CompareContent from '@/components/shop/configurator/CompareContent'

interface ComparePageProps {
searchParams: Promise<{ bikes?: string }>
}

const MIN_BIKES = 2
const MAX_BIKES = 3

export default async function ComparePage({ searchParams }: ComparePageProps) {
const { bikes: bikesParam } = await searchParams

const ids = parseAndValidateIds(bikesParam)

if (ids.length < MIN_BIKES) {
return (
<ComparePageShell>
<EmptyState
title="Pick 2 or 3 bikes to compare"
body={
bikesParam
? `The link \`?bikes=${bikesParam}\` didn't resolve to enough unique bikes. Select bikes from the shop and try again.`
: 'Head to the shop, pick a couple of bikes from the compare section, and we\'ll line them up here.'
}
/>
</ComparePageShell>
)
}

const bikes = await getManyBikes(ids)
if (!bikes || bikes.length < MIN_BIKES) notFound()

// Preserve the requested order (Supabase doesn't guarantee result order matches the IN list).
const byId = new Map<string, Bike>(bikes.map((b) => [String(b.bike_id), b]))
const orderedBikes = ids.map((id) => byId.get(id)).filter((b): b is Bike => Boolean(b))
if (orderedBikes.length < MIN_BIKES) notFound()

return (
<ComparePageShell>
<CompareContent bikes={orderedBikes} />
</ComparePageShell>
)
}

function parseAndValidateIds(raw: string | undefined): string[] {
if (!raw) return []
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
const numericOnly = parts.filter((p) => /^\d+$/.test(p))
const unique = Array.from(new Set(numericOnly))
return unique.slice(0, MAX_BIKES)
}

function ComparePageShell({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-background">{children}</div>
}

function EmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="max-w-2xl mx-auto px-4 py-24 text-center">
<h1 className="text-2xl font-bold text-text-primary mb-2">{title}</h1>
<p className="text-text-muted">{body}</p>
<Link
href="/"
className="inline-block mt-6 text-sm text-text-link hover:text-text-link-hover"
>
← Back to shop
</Link>
</div>
)
}
31 changes: 20 additions & 11 deletions app/products/[productId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import dynamic from "next/dynamic";
import { Bike } from "@/utils/getBike";
import CartAdd from "./cartAdd";
import CompareWithPicker from "@/components/shop/configurator/CompareWithPicker";
import BikeViewerSkeleton from "@/components/shop/configurator/BikeViewerSkeleton";
import ReviewForm from "./ReviewForm";
import LoadingSpinner from "@/components/ui/LoadingSpinner";
import { createClient } from "@/utils/supabase/client";
import { useParams, useSearchParams } from "next/navigation";

const BikeViewer = dynamic(
() => import("@/components/shop/configurator/BikeViewer"),
{
ssr: false,
loading: () => <BikeViewerSkeleton />,
},
);

function CartNotification({
bike,
subtotal,
Expand Down Expand Up @@ -209,18 +220,13 @@ export default function ProductPage() {
{/* Main Product Section */}
<div className="bg-surface rounded-2xl shadow-lg p-8 mb-12 border border-border">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Product Image */}
{/* Product viewer — interactive 3D primitive replaces the static image */}
<div className="flex justify-center">
<div className="bg-btn-background rounded-2xl p-8 w-full max-w-md border border-border-subtle">
<Image
src={bike.image || "/img/placeholder.png"}
alt={bike.name}
unoptimized
width={400}
height={400}
style={{ objectFit: "contain" }}
className="w-full h-auto"
/>
<div className="bg-surface rounded-2xl w-full max-w-md border border-border-subtle aspect-square overflow-hidden relative">
<BikeViewer bike={bike} className="absolute inset-0" />
<div className="absolute top-2 left-2 text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded-sm border border-dashed border-border text-text-muted pointer-events-none">
3D preview · placeholder geometry
</div>
</div>
</div>

Expand Down Expand Up @@ -251,6 +257,9 @@ export default function ProductPage() {
/>
</div>

{/* Compare with another bike */}
<CompareWithPicker currentBike={bike} />

{/* Shipping Information */}
<div className="border-t border-border pt-6 space-y-2">
<h3 className="font-semibold text-text-primary">
Expand Down
139 changes: 72 additions & 67 deletions components/shop/ProductComparison.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { createClient } from "@/utils/supabase/client";
import { type Bike } from "@/app/lib/definitions";

const MAX_COMPARE = 3;
const MIN_COMPARE = 2;

const ProductComparison = () => {
const router = useRouter();
const supabase = createClient();
const [bikes, setBikes] = useState<Bike[]>([]);
const [selectedBikes, setSelectedBikes] = useState<Bike[]>([]);
Expand All @@ -26,88 +32,87 @@ const ProductComparison = () => {
if (prevSelectedBikes.find((b) => b.bike_id === bike.bike_id)) {
return prevSelectedBikes.filter((b) => b.bike_id !== bike.bike_id);
} else {
if (prevSelectedBikes.length < 3) {
if (prevSelectedBikes.length < MAX_COMPARE) {
return [...prevSelectedBikes, bike];
} else {
alert("You can only compare up to 3 bikes.");
toast.warning(`You can only compare up to ${MAX_COMPARE} bikes at a time.`);
return prevSelectedBikes;
}
}
});
};

const handleCompare = () => {
if (selectedBikes.length < MIN_COMPARE) return;
const ids = selectedBikes.map((b) => b.bike_id).join(",");
router.push(`/compare?bikes=${ids}`);
};

const canCompare = selectedBikes.length >= MIN_COMPARE;
const slotsLeft = MAX_COMPARE - selectedBikes.length;

return (
<div className="flex">
<div className="container mx-auto p-4 w-full">
<h2 className="text-2xl font-bold mb-4">Compare Products</h2>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
{bikes.map((bike) => (
<div
key={bike.bike_id}
className={`p-4 border rounded-lg cursor-pointer ${
selectedBikes.find((b) => b.bike_id === bike.bike_id)
? "border-blue-500"
: ""
}`}
onClick={() => handleSelectBike(bike)}
>
<img
src={bike.image || ''}
alt={bike.name}
className="w-full h-32 object-contain mb-2"
/>
<h3 className="font-bold">{bike.name}</h3>
</div>
))}
<div className="flex items-baseline justify-between flex-wrap gap-3 mb-4">
<div>
<h2 className="text-2xl font-bold text-text-primary">Compare bikes</h2>
<p className="text-sm text-text-muted">
Pick {MIN_COMPARE}–{MAX_COMPARE} bikes, then open the 3D comparison view.
</p>
</div>
<div className="text-sm text-text-muted">
{selectedBikes.length === 0
? `Nothing selected`
: `${selectedBikes.length} selected${slotsLeft > 0 ? ` · ${slotsLeft} more allowed` : ' · maximum'}`}
</div>
</div>

{selectedBikes.length > 0 && (
<table className="w-full border-collapse border border-gray-600">
<thead>
<tr className="bg-btn-primary text-btn-primary-text">
<th className="p-2 border border-gray-600">Feature</th>
{selectedBikes.map((bike) => (
<th key={bike.bike_id} className="p-2 border border-gray-600">
{bike.name}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="p-2 border border-gray-600 font-bold">Price</td>
{selectedBikes.map((bike) => (
<td key={bike.bike_id} className="p-2 border border-gray-600">
${bike.sell_price}
</td>
))}
</tr>
<tr>
<td className="p-2 border border-gray-600 font-bold">
Rental Rate
</td>
{selectedBikes.map((bike) => (
<td key={bike.bike_id} className="p-2 border border-gray-600">
${bike.rental_rate}/day
</td>
))}
</tr>
<tr>
<td className="p-2 border border-gray-600 font-bold">
In Stock
</td>
{selectedBikes.map((bike) => (
<td key={bike.bike_id} className="p-2 border border-gray-600">
{bike.amount_stocked}
</td>
))}
</tr>
</tbody>
</table>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
{bikes.map((bike) => {
const isSelected = selectedBikes.some((b) => b.bike_id === bike.bike_id);
return (
<button
key={bike.bike_id}
type="button"
onClick={() => handleSelectBike(bike)}
aria-pressed={isSelected}
className={[
"p-4 border rounded-lg text-left transition-colors bg-surface",
isSelected
? "border-btn-primary ring-2 ring-btn-primary/30"
: "border-border hover:border-border-hover",
].join(" ")}
>
<img
src={bike.image || ""}
alt={bike.name}
className="w-full h-32 object-contain mb-2"
/>
<h3 className="font-bold text-text-primary">{bike.name}</h3>
</button>
);
})}
</div>

<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 p-4 bg-surface rounded-xl border border-border">
<div className="text-sm text-text-secondary">
{canCompare
? `Ready to compare ${selectedBikes.length} bikes side-by-side in 3D.`
: `Select at least ${MIN_COMPARE} bikes to enable the 3D comparison.`}
</div>
<button
type="button"
disabled={!canCompare}
onClick={handleCompare}
className="px-5 py-2.5 rounded-md bg-btn-primary text-btn-primary-text font-semibold hover:bg-btn-primary-hover disabled:bg-btn-disabled disabled:text-btn-disabled-text disabled:cursor-not-allowed transition-colors whitespace-nowrap"
>
Compare these in 3D →
</button>
</div>
</div>
</div>
);
};

export default ProductComparison;
export default ProductComparison;
Loading