add skeleton resolvers

This commit is contained in:
Rami Bitar
2026-05-03 19:31:41 -04:00
parent 5d854c5d75
commit ec68676b23
10 changed files with 168 additions and 134 deletions

View File

@@ -3,6 +3,7 @@
import React from 'react';
import { useCollectionProducts } from '@/editor/hooks/use-shopify-collections';
import ProductCard from './product-card';
import { Skeleton } from '@/components/ui/skeleton';
const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
const handle = handleProp ?? '';
@@ -14,25 +15,20 @@ const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp })
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
: 'Collection';
if (loading) {
if (loading || !handle) {
return (
<div className="pt-4 pb-16">
<div className="container mx-auto px-4">
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading">
{formattedTitle}
</h2>
<div className="mb-16 flex justify-center">
<Skeleton className="h-12 w-64" />
</div>
{/* Loading Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse">
<div className="aspect-square bg-gray-200"></div>
<div className="p-6">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-4"></div>
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-12 bg-gray-200 rounded"></div>
</div>
<div key={index} className="flex flex-col gap-3">
<Skeleton className="aspect-square w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>

View File

@@ -2,6 +2,7 @@ import type { ShopifyCollection } from "@reacteditor/field-shopify";
import { useCollectionProducts } from "@/editor/hooks/use-shopify-collections";
import { ProductCard } from "./product-card";
import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
export type CollectionProps = {
collection: ShopifyCollection | null;
@@ -17,10 +18,19 @@ export function CollectionView({
if (!selected) {
return (
<section className="py-20">
<section className="bg-background pb-24 pt-12 md:pt-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a collection to render this page.
<header className="mx-auto mb-14 flex max-w-2xl flex-col items-center gap-3 text-center">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-3/4" />
{showDescription === "yes" ? (
<Skeleton className="mt-1 h-5 w-2/3" />
) : null}
</header>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))}
</div>
</div>
</section>
@@ -50,10 +60,7 @@ export function CollectionView({
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
{loading
? Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
<Skeleton key={i} className="aspect-[4/5] w-full" />
))
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>

View File

@@ -3,6 +3,8 @@ import type { ShopifyProduct } from "@reacteditor/field-shopify";
import { useProduct } from "@/editor/hooks/use-shopify-products";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
export type FeaturedProductProps = {
product: ShopifyProduct | null;
@@ -23,21 +25,31 @@ export function FeaturedProductView({
const product: any = full ?? selected;
const cart = useShopifyCart();
if (!selected && loading) {
return (
<section className="py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
<div className="aspect-[2/1] w-full animate-pulse rounded-lg bg-muted" />
</div>
</section>
);
}
if (!product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to feature here.
<section
className={cn(
"py-20 md:py-28",
tone === "muted" ? "bg-muted/40" : "bg-background",
)}
>
<div className="container mx-auto grid max-w-7xl grid-cols-1 items-center gap-10 px-6 md:grid-cols-2 md:gap-16">
<div className={cn(align === "right" && "md:order-2")}>
<Skeleton className="aspect-[4/5] w-full" />
</div>
<div className="flex w-full flex-col items-start gap-5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-6 w-32" />
<div className="w-full max-w-md space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
<div className="mt-2 flex flex-wrap gap-3">
<Skeleton className="h-11 w-32 rounded-full" />
<Skeleton className="h-11 w-32 rounded-full" />
</div>
</div>
</div>
</section>

View File

@@ -9,6 +9,7 @@ import ProductDetailInfo from './product-detail-info';
import ProductRecommendations from './product-recommendations';
import { Button } from '@/editor/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/editor/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
Breadcrumb,
BreadcrumbList,
@@ -111,40 +112,14 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image Gallery Skeleton */}
<div>
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse mb-4"></div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded animate-pulse"></div>
))}
</div>
</div>
{/* Product Info Skeleton */}
<div>
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div>
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div>
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
</div>
);
}
if (error || !product) {
if (loading || !handle || !product) {
if (error && handle && !loading) {
return (
<div className="container mx-auto px-4 py-12">
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
<p className="text-sm font-medium">Product not found</p>
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
{error || "The requested product could not be found."}
{error}
</p>
<Button
size="sm"
@@ -158,6 +133,30 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
);
}
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div>
<Skeleton className="aspect-square w-full mb-4" />
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full" />
))}
</div>
</div>
<div className="flex flex-col gap-4">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8">

View File

@@ -5,6 +5,7 @@ import { useProduct } from "@/editor/hooks/use-shopify-products";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Typography } from "@/editor/theme/Typography";
import { cn } from "@/editor/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
export type ProductDetailsProps = {
product: ShopifyProduct | null;
@@ -26,42 +27,39 @@ export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
}
}, [product]);
if (!handle) {
if (!handle || loading || !product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to render this page.
<section className="bg-background py-12 md:py-20">
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-10 px-6 md:grid-cols-2 md:gap-16">
<div className="flex flex-col gap-4">
<Skeleton className="aspect-[4/5] w-full" />
<div className="flex gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-20 flex-shrink-0" />
))}
</div>
</div>
</section>
);
}
if (loading) {
return (
<section className="py-20">
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 md:grid-cols-2">
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
<div className="space-y-4">
<div className="h-8 w-3/4 animate-pulse rounded bg-muted" />
<div className="h-5 w-1/4 animate-pulse rounded bg-muted" />
<div className="h-32 w-full animate-pulse rounded bg-muted" />
<div className="flex flex-col gap-6">
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-6 w-1/4" />
<div className="flex flex-col gap-3">
<Skeleton className="h-3 w-16" />
<div className="flex gap-2">
<Skeleton className="h-10 w-16 rounded-full" />
<Skeleton className="h-10 w-16 rounded-full" />
<Skeleton className="h-10 w-16 rounded-full" />
</div>
</div>
</section>
);
}
if (!product) {
return (
<section className="py-20">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-border p-6 text-sm">
<p className="font-medium">Product not found</p>
<p className="mt-2 text-muted-foreground">
The requested product could not be found.
</p>
<div className="flex items-center gap-4 pt-2">
<Skeleton className="h-11 w-32 rounded-full" />
<Skeleton className="h-11 flex-1 rounded-full" />
</div>
<div className="space-y-2 border-t border-border pt-6">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
</div>
</section>

View File

@@ -5,6 +5,7 @@ import {
} from "@/editor/hooks/use-shopify-products";
import { ProductCard } from "./product-card";
import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
export type RecommendedProductsProps = {
product: ShopifyProduct | null;
@@ -25,10 +26,16 @@ export function RecommendedProductsView({
if (!selected) {
return (
<section className="py-20">
<section className="bg-background py-20 md:py-28">
<div className="container mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Pick a product to load recommendations.
<div className="mb-12 flex max-w-xl flex-col gap-3">
{tagline ? <Skeleton className="h-3 w-24" /> : null}
<Skeleton className="h-8 w-2/3" />
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
{Array.from({ length: limit }).map((_, i) => (
<Skeleton key={i} className="aspect-[4/5] w-full" />
))}
</div>
</div>
</section>
@@ -50,10 +57,7 @@ export function RecommendedProductsView({
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
{items.length === 0
? Array.from({ length: limit }).map((_, i) => (
<div
key={i}
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
/>
<Skeleton key={i} className="aspect-[4/5] w-full" />
))
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
</div>

View File

@@ -16,10 +16,11 @@ export const navigationEditor: ComponentConfig<NavigationProps> = {
{ label: "About", href: "/about" },
],
showSearch: "yes",
showAccount: "yes",
showCart: "yes",
sticky: "yes",
tone: "default",
bannerText: "",
bannerTone: "accent",
},
fields: {
brand: { label: "Brand", type: "text", contentEditable: true },
@@ -33,16 +34,22 @@ export const navigationEditor: ComponentConfig<NavigationProps> = {
href: { label: "Link", type: "text" },
},
},
showSearch: {
label: "Search icon",
type: "radio",
bannerText: {
label: "Banner text",
type: "text",
contentEditable: true,
},
bannerTone: {
label: "Banner tone",
type: "select",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
{ label: "Default", value: "default" },
{ label: "Accent", value: "accent" },
{ label: "Inverse (dark)", value: "inverse" },
],
},
showAccount: {
label: "Account icon",
showSearch: {
label: "Search icon",
type: "radio",
options: [
{ label: "Show", value: "yes" },

View File

@@ -1,4 +1,4 @@
import { Menu as MenuIcon, ShoppingBag, Search, User } from "lucide-react";
import { Menu as MenuIcon, ShoppingBag, Search } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
@@ -9,20 +9,22 @@ export type NavigationProps = {
brand: string;
links: Array<{ label: string; href: string }>;
showSearch: "yes" | "no";
showAccount: "yes" | "no";
showCart: "yes" | "no";
sticky: "yes" | "no";
tone: "default" | "muted" | "inverse";
bannerText: string;
bannerTone: "default" | "accent" | "inverse";
};
export function Navigation({
brand,
links,
showSearch,
showAccount,
showCart,
sticky,
tone,
bannerText,
bannerTone,
}: NavigationProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const [cartOpen, setCartOpen] = useState(false);
@@ -35,12 +37,33 @@ export function Navigation({
inverse: "bg-foreground text-background",
};
const bannerToneClass: Record<NavigationProps["bannerTone"], string> = {
default: "bg-muted text-foreground",
accent: "bg-primary text-primary-foreground",
inverse: "bg-foreground text-background",
};
const hasBanner = typeof bannerText === "string" && bannerText.trim().length > 0;
return (
<>
<div
className={cn(
"w-full",
sticky === "yes" && "sticky top-0 z-40",
)}
>
{hasBanner && (
<div className={cn("w-full", bannerToneClass[bannerTone])}>
<div className="container mx-auto max-w-7xl px-6 py-2 text-center text-xs tracking-wide md:text-sm">
{bannerText}
</div>
</div>
)}
<header
className={cn(
"w-full",
sticky === "yes" && "sticky top-0 z-40 backdrop-blur",
sticky === "yes" && "backdrop-blur",
toneClass[tone],
)}
>
@@ -74,15 +97,6 @@ export function Navigation({
<Search size={18} strokeWidth={1.5} />
</button>
)}
{showAccount === "yes" && (
<Link
to="/account"
aria-label="Account"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<User size={18} strokeWidth={1.5} />
</Link>
)}
{showCart === "yes" && (
<button
onClick={() => setCartOpen(true)}
@@ -107,6 +121,7 @@ export function Navigation({
</div>
</div>
</header>
</div>
{/* Mobile menu */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Quote, ArrowLeft, ArrowRight } from "lucide-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Typography } from "@/editor/theme/Typography";
export type TestimonialsProps = {
@@ -30,16 +30,11 @@ export function Testimonials({ tagline, heading, items }: TestimonialsProps) {
{item ? (
<figure className="mx-auto mt-12 flex max-w-2xl flex-col items-center">
<Quote
size={28}
strokeWidth={1.25}
className="mb-6 text-muted-foreground"
/>
<blockquote
className="text-balance text-foreground"
style={{ fontSize: "clamp(1.25rem, 2.4vw, 1.75rem)", lineHeight: 1.4 }}
>
"{item.quote}"
{item.quote}
</blockquote>
<figcaption className="mt-8 flex items-center gap-3">
{item.avatar ? (

View File

@@ -26,10 +26,11 @@ export const initialData: Record<string, UserData> = {
],
cta: { label: "", href: "" },
showSearch: "yes",
showAccount: "yes",
showCart: "yes",
sticky: "yes",
tone: "default",
bannerText: "",
bannerTone: "accent",
},
},
{