Feat: Add loading spinner for made-to-order page

- Create LoadingSpinner component with spinning circle
- Wrap products grid with Suspense boundary
- Shows spinner while products are loading
- Improves UX for slower connections

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-21 19:30:32 +01:00
co-authored by Claude Haiku 4.5
parent e716e8256c
commit dc9bceb494
3 changed files with 70 additions and 47 deletions
+2 -1
View File
@@ -160,7 +160,8 @@
"Bash(curl -s http://127.0.0.1:3000/made-to-order)",
"Bash(curl -s \"http://127.0.0.1:3000/products/awd-hoodie\")",
"Bash(git commit -m 'Fix: NavDropdown link navigation with stopPropagation *)",
"Bash(git commit -m 'Feat: Remove all filters from products pages *)"
"Bash(git commit -m 'Feat: Remove all filters from products pages *)",
"Bash(git commit -m 'Feat: Add loading spinner for made-to-order page *)"
]
}
}
+58 -46
View File
@@ -2,6 +2,8 @@ import { prisma } from '@/lib/prisma';
import { toProductDTO } from '@/lib/product';
import { fetchActivePromotions } from '@/lib/promotions';
import ProductCard from '@/components/ProductCard';
import LoadingSpinner from '@/components/LoadingSpinner';
import { Suspense } from 'react';
type SearchParams = {
page?: string;
@@ -9,13 +11,7 @@ type SearchParams = {
const PRODUCTS_PER_PAGE = 20;
function toArray(v?: string | string[]) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const currentPage = Math.max(1, Number(searchParams.page) || 1);
async function ProductsGrid({ currentPage }: { currentPage: number }) {
const activePromotions = await fetchActivePromotions();
const [rows, totalCount] = await Promise.all([
@@ -31,6 +27,13 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
const products = rows.map((p) => toProductDTO(p, activePromotions, 'personalised'));
const totalPages = Math.ceil(totalCount / PRODUCTS_PER_PAGE);
return { products, totalPages, totalCount, currentPage };
}
export default async function ProductsPage({ searchParams }: { searchParams: SearchParams }) {
const currentPage = Math.max(1, Number(searchParams.page) || 1);
const totalCount = await prisma.product.count({ where: { showOnPersonalised: true } });
return (
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="mb-12 flex items-baseline justify-between">
@@ -40,47 +43,56 @@ export default async function ProductsPage({ searchParams }: { searchParams: Sea
</p>
</div>
<div>
{/* Sidebar filters */}
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
<Suspense fallback={<LoadingSpinner />}>
<ProductsGridContent currentPage={currentPage} totalCount={totalCount} />
</Suspense>
</div>
);
}
async function ProductsGridContent({ currentPage, totalCount }: { currentPage: number; totalCount: number }) {
const { products, totalPages } = await ProductsGrid({ currentPage });
return (
<div>
{products.length === 0 ? (
<div className="border border-line bg-surface p-12 text-center">
<p className="font-display text-xl">Nothing here yet</p>
<p className="mt-2 text-sm text-muted">Templates will show up here once created.</p>
</div>
) : (
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
) : (
<div>
<div className="grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/made-to-order?page=${currentPage - 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/made-to-order?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
{totalPages > 1 && (
<div className="mt-12 flex items-center justify-center gap-4 border-t border-line pt-8">
{currentPage > 1 && (
<a
href={`/made-to-order?page=${currentPage - 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Previous
</a>
)}
<p className="text-sm text-muted">
Page {currentPage} of {totalPages}
</p>
{currentPage < totalPages && (
<a
href={`/made-to-order?page=${currentPage + 1}`}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
Next
</a>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export default function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-24">
<div className="relative h-12 w-12">
<div className="absolute inset-0 rounded-full border-4 border-line"></div>
<div className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-ink"></div>
</div>
</div>
);
}