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

View File

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

View File

@@ -3,6 +3,8 @@ import type { ShopifyProduct } from "@reacteditor/field-shopify";
import { useProduct } from "@/editor/hooks/use-shopify-products"; import { useProduct } from "@/editor/hooks/use-shopify-products";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart"; import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Typography } from "@/editor/theme/Typography"; import { Typography } from "@/editor/theme/Typography";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
export type FeaturedProductProps = { export type FeaturedProductProps = {
product: ShopifyProduct | null; product: ShopifyProduct | null;
@@ -23,21 +25,31 @@ export function FeaturedProductView({
const product: any = full ?? selected; const product: any = full ?? selected;
const cart = useShopifyCart(); 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) { if (!product) {
return ( return (
<section className="py-20"> <section
<div className="container mx-auto max-w-7xl px-6"> className={cn(
<div className="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> "py-20 md:py-28",
Pick a product to feature here. 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>
</div> </div>
</section> </section>

View File

@@ -9,6 +9,7 @@ import ProductDetailInfo from './product-detail-info';
import ProductRecommendations from './product-recommendations'; import ProductRecommendations from './product-recommendations';
import { Button } from '@/editor/components/ui/button'; import { Button } from '@/editor/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/editor/components/ui/alert'; import { Alert, AlertTitle, AlertDescription } from '@/editor/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbList, BreadcrumbList,
@@ -111,53 +112,51 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
} }
}; };
if (loading) { 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}
</p>
<Button
size="sm"
variant="outline"
onClick={() => window.history.back()}
>
Go back
</Button>
</div>
</div>
);
}
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image Gallery Skeleton */}
<div> <div>
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse mb-4"></div> <Skeleton className="aspect-square w-full mb-4" />
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded animate-pulse"></div> <Skeleton key={i} className="aspect-square w-full" />
))} ))}
</div> </div>
</div> </div>
{/* Product Info Skeleton */} <div className="flex flex-col gap-4">
<div> <Skeleton className="h-8 w-3/4" />
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div> <Skeleton className="h-6 w-1/3" />
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div> <Skeleton className="h-24 w-full" />
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div> <Skeleton className="h-12 w-full" />
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div> <Skeleton className="h-12 w-full" />
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (error || !product) {
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."}
</p>
<Button
size="sm"
variant="outline"
onClick={() => window.history.back()}
>
Go back
</Button>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-8"> <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 { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
import { Typography } from "@/editor/theme/Typography"; import { Typography } from "@/editor/theme/Typography";
import { cn } from "@/editor/lib/utils"; import { cn } from "@/editor/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
export type ProductDetailsProps = { export type ProductDetailsProps = {
product: ShopifyProduct | null; product: ShopifyProduct | null;
@@ -26,42 +27,39 @@ export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
} }
}, [product]); }, [product]);
if (!handle) { if (!handle || loading || !product) {
return ( return (
<section className="py-20"> <section className="bg-background py-12 md:py-20">
<div className="container mx-auto max-w-7xl px-6"> <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="mx-auto max-w-md rounded-md border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> <div className="flex flex-col gap-4">
Pick a product to render this page. <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> </div>
</div> <div className="flex flex-col gap-6">
</section> <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" />
if (loading) { <div className="flex gap-2">
return ( <Skeleton className="h-10 w-16 rounded-full" />
<section className="py-20"> <Skeleton className="h-10 w-16 rounded-full" />
<div className="container mx-auto grid max-w-7xl grid-cols-1 gap-12 px-6 md:grid-cols-2"> <Skeleton className="h-10 w-16 rounded-full" />
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" /> </div>
<div className="space-y-4"> </div>
<div className="h-8 w-3/4 animate-pulse rounded bg-muted" /> <div className="flex items-center gap-4 pt-2">
<div className="h-5 w-1/4 animate-pulse rounded bg-muted" /> <Skeleton className="h-11 w-32 rounded-full" />
<div className="h-32 w-full animate-pulse rounded bg-muted" /> <Skeleton className="h-11 flex-1 rounded-full" />
</div> </div>
</div> <div className="space-y-2 border-t border-border pt-6">
</section> <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" />
if (!product) { </div>
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> </div>
</div> </div>
</section> </section>

View File

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

View File

@@ -16,10 +16,11 @@ export const navigationEditor: ComponentConfig<NavigationProps> = {
{ label: "About", href: "/about" }, { label: "About", href: "/about" },
], ],
showSearch: "yes", showSearch: "yes",
showAccount: "yes",
showCart: "yes", showCart: "yes",
sticky: "yes", sticky: "yes",
tone: "default", tone: "default",
bannerText: "",
bannerTone: "accent",
}, },
fields: { fields: {
brand: { label: "Brand", type: "text", contentEditable: true }, brand: { label: "Brand", type: "text", contentEditable: true },
@@ -33,16 +34,22 @@ export const navigationEditor: ComponentConfig<NavigationProps> = {
href: { label: "Link", type: "text" }, href: { label: "Link", type: "text" },
}, },
}, },
showSearch: { bannerText: {
label: "Search icon", label: "Banner text",
type: "radio", type: "text",
contentEditable: true,
},
bannerTone: {
label: "Banner tone",
type: "select",
options: [ options: [
{ label: "Show", value: "yes" }, { label: "Default", value: "default" },
{ label: "Hide", value: "no" }, { label: "Accent", value: "accent" },
{ label: "Inverse (dark)", value: "inverse" },
], ],
}, },
showAccount: { showSearch: {
label: "Account icon", label: "Search icon",
type: "radio", type: "radio",
options: [ options: [
{ label: "Show", value: "yes" }, { 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 { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { useShopifyCart } from "@/editor/hooks/use-shopify-cart"; import { useShopifyCart } from "@/editor/hooks/use-shopify-cart";
@@ -9,20 +9,22 @@ export type NavigationProps = {
brand: string; brand: string;
links: Array<{ label: string; href: string }>; links: Array<{ label: string; href: string }>;
showSearch: "yes" | "no"; showSearch: "yes" | "no";
showAccount: "yes" | "no";
showCart: "yes" | "no"; showCart: "yes" | "no";
sticky: "yes" | "no"; sticky: "yes" | "no";
tone: "default" | "muted" | "inverse"; tone: "default" | "muted" | "inverse";
bannerText: string;
bannerTone: "default" | "accent" | "inverse";
}; };
export function Navigation({ export function Navigation({
brand, brand,
links, links,
showSearch, showSearch,
showAccount,
showCart, showCart,
sticky, sticky,
tone, tone,
bannerText,
bannerTone,
}: NavigationProps) { }: NavigationProps) {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [cartOpen, setCartOpen] = useState(false); const [cartOpen, setCartOpen] = useState(false);
@@ -35,12 +37,33 @@ export function Navigation({
inverse: "bg-foreground text-background", 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 ( 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 <header
className={cn( className={cn(
"w-full", "w-full",
sticky === "yes" && "sticky top-0 z-40 backdrop-blur", sticky === "yes" && "backdrop-blur",
toneClass[tone], toneClass[tone],
)} )}
> >
@@ -74,15 +97,6 @@ export function Navigation({
<Search size={18} strokeWidth={1.5} /> <Search size={18} strokeWidth={1.5} />
</button> </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" && ( {showCart === "yes" && (
<button <button
onClick={() => setCartOpen(true)} onClick={() => setCartOpen(true)}
@@ -107,6 +121,7 @@ export function Navigation({
</div> </div>
</div> </div>
</header> </header>
</div>
{/* Mobile menu */} {/* Mobile menu */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>

View File

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

View File

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