Initial commit
This commit is contained in:
204
editor/components/commerce/cart-drawer.tsx
Normal file
204
editor/components/commerce/cart-drawer.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useShopifyCart, redirectToCheckout } from '@/editor/hooks/use-shopify-cart';
|
||||
import { Button } from '@/editor/components/ui/button';
|
||||
import { Spinner } from '@/editor/components/ui/spinner';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
} from '@/editor/components/ui/sheet';
|
||||
|
||||
const CartDrawer: React.FC = () => {
|
||||
const { isOpen, closeCart, items, itemCount, totalAmount, checkoutUrl, loading, removeItem, updateItemQuantity } = useShopifyCart();
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (checkoutUrl) {
|
||||
redirectToCheckout(checkoutUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const getItemImage = (item: typeof items[0]) => {
|
||||
return item.merchandise.image?.url;
|
||||
};
|
||||
|
||||
const getSelectedOptions = (item: typeof items[0]) => {
|
||||
return item.merchandise.selectedOptions ?? [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()} side="right">
|
||||
<SheetContent className="w-full sm:max-w-md">
|
||||
{/* Header */}
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-base">
|
||||
Shopping Cart ({itemCount})
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Cart Items */}
|
||||
<SheetBody>
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<i className="ri-shopping-cart-line text-6xl text-gray-300 mb-4 block"></i>
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">Your cart is empty</h3>
|
||||
<p className="text-gray-500 mb-6">Add some products to get started!</p>
|
||||
<Button
|
||||
onClick={closeCart}
|
||||
className="font-heading"
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const image = getItemImage(item);
|
||||
const selectedOptions = getSelectedOptions(item);
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex items-start space-x-4 pb-6 border-b border-gray-200 last:border-b-0">
|
||||
{/* Product Image */}
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={item.merchandise.product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<i className="ri-image-line text-2xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-gray-900 mb-1 line-clamp-2">
|
||||
{item.merchandise.product.title}
|
||||
</h4>
|
||||
|
||||
{/* Variant Info */}
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
{selectedOptions.map((option, index) => (
|
||||
<span key={option.name}>
|
||||
{option.value}
|
||||
{index < selectedOptions.length - 1 ? ' / ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center mt-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||
<Button
|
||||
onClick={() => updateItemQuantity(item.id, item.quantity - 1)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={item.quantity <= 1 || loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<i className="ri-subtract-line text-sm"></i>
|
||||
</Button>
|
||||
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => updateItemQuantity(item.id, item.quantity + 1)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<i className="ri-add-line text-sm"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
${parseFloat(item.merchandise.price.amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => removeItem(item.id)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<i className="ri-delete-bin-line text-lg"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SheetBody>
|
||||
|
||||
{/* Footer - Checkout Section */}
|
||||
{items.length > 0 && (
|
||||
<SheetFooter className="flex-col sm:flex-col sm:justify-start gap-0">
|
||||
{/* Subtotal */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-base font-semibold">Subtotal</span>
|
||||
<span className="text-lg font-bold">
|
||||
${totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
Shipping and taxes calculated at checkout
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={loading || !checkoutUrl}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center space-x-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Processing...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Checkout'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={closeCart}
|
||||
variant="link"
|
||||
className="w-full"
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartDrawer;
|
||||
64
editor/components/commerce/collection-card.tsx
Normal file
64
editor/components/commerce/collection-card.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Card, CardContent } from '@/editor/components/ui/card';
|
||||
|
||||
interface CollectionImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
image?: CollectionImage;
|
||||
}
|
||||
|
||||
interface CollectionCardProps {
|
||||
collection: Collection;
|
||||
}
|
||||
|
||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
return (
|
||||
<Link to={`/collections/${collection.handle}`} className="block group">
|
||||
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
|
||||
{/* Collection Image */}
|
||||
<div className="aspect-video overflow-hidden bg-gray-100">
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image.url}
|
||||
alt={collection.image.altText || collection.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<i className="ri-folder-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-3 group-hover:text-gray-600 transition-colors font-heading">
|
||||
{collection.title}
|
||||
</h3>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-gray-600">
|
||||
{collection.description.substring(0, 100)}
|
||||
{collection.description.length > 100 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-black font-semibold group-hover:text-gray-600 transition-colors flex items-center">
|
||||
<span>View Collection</span>
|
||||
<i className="ri-arrow-right-s-line ml-2"></i>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionCard;
|
||||
95
editor/components/commerce/collection-detail.tsx
Normal file
95
editor/components/commerce/collection-detail.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
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 ?? '';
|
||||
|
||||
const { collection, loading, error, refetch } = useCollectionProducts(handle);
|
||||
|
||||
// Format title from handle
|
||||
const formattedTitle = handle
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
: 'Collection';
|
||||
|
||||
if (loading || !handle) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-16 flex justify-center">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<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">Could not load collection</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const products = collection?.products || [];
|
||||
const title = collection?.title || formattedTitle;
|
||||
|
||||
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">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">
|
||||
No Products in Collection
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
This collection doesn't have any products yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionDetail;
|
||||
31
editor/components/commerce/collection-grid.editor.tsx
Normal file
31
editor/components/commerce/collection-grid.editor.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { CollectionGrid, type CollectionGridProps } from "@/editor/components/commerce/collection-grid";
|
||||
|
||||
export const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
|
||||
label: "Collections",
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
tagline: "Shop by collection",
|
||||
heading: "Curated edits",
|
||||
subheading: "Bundles built around the way you actually live.",
|
||||
layout: "tiles",
|
||||
limit: 6,
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Tiles", value: "tiles" },
|
||||
{ label: "Editorial", value: "editorial" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 12 },
|
||||
},
|
||||
render: (props) => <CollectionGrid {...props} />,
|
||||
};
|
||||
116
editor/components/commerce/collection-grid.tsx
Normal file
116
editor/components/commerce/collection-grid.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { shopifyFetch } from "@/editor/services/shopify/client";
|
||||
import { GET_COLLECTIONS_QUERY } from "@/editor/graphql/collections";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type CollectionGridProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
layout: "tiles" | "editorial";
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type CollectionRow = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: { url: string; altText?: string };
|
||||
};
|
||||
|
||||
export function CollectionGrid({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
layout,
|
||||
limit,
|
||||
}: CollectionGridProps) {
|
||||
const [collections, setCollections] = useState<CollectionRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
shopifyFetch<any>({
|
||||
query: GET_COLLECTIONS_QUERY,
|
||||
variables: { first: limit },
|
||||
})
|
||||
.then((res) => {
|
||||
const list = (res.data?.collections?.edges ?? []).map((e: any) => e.node);
|
||||
setCollections(list);
|
||||
})
|
||||
.catch(() => setCollections([]));
|
||||
}, [limit]);
|
||||
|
||||
const isEditorial = layout === "editorial";
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<div className="mx-auto mb-12 max-w-2xl text-center">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isEditorial
|
||||
? "grid grid-cols-1 gap-8 md:grid-cols-2"
|
||||
: "grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4"
|
||||
}
|
||||
>
|
||||
{(collections.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }) as any)
|
||||
: collections
|
||||
).map((c: CollectionRow) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={c.handle ? `/collections/${c.handle}` : "#"}
|
||||
className="group block"
|
||||
>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-md bg-muted ${isEditorial ? "aspect-[3/4] md:aspect-[5/6]" : "aspect-[4/5]"}`}
|
||||
>
|
||||
{c.image?.url ? (
|
||||
<img
|
||||
src={c.image.url}
|
||||
alt={c.image.altText || c.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
{isEditorial ? (
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-8">
|
||||
<div>
|
||||
<Typography variant="h4" className="text-white">
|
||||
{c.title}
|
||||
</Typography>
|
||||
<span className="mt-2 inline-flex text-xs uppercase tracking-[0.2em] text-white/80">
|
||||
Shop now →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isEditorial ? (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium tracking-tight">{c.title}</h3>
|
||||
<span className="text-xs text-muted-foreground transition-opacity group-hover:opacity-100">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
26
editor/components/commerce/collection.editor.tsx
Normal file
26
editor/components/commerce/collection.editor.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { CollectionView, type CollectionProps } from "@/editor/components/commerce/collection";
|
||||
|
||||
export function createCollectionEditor(opts: {
|
||||
collectionField: any;
|
||||
}): ComponentConfig<CollectionProps> {
|
||||
return {
|
||||
label: "Collection page",
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: { collection: null, showDescription: "no" },
|
||||
fields: {
|
||||
collection: { label: "Collection", ...opts.collectionField },
|
||||
showDescription: {
|
||||
label: "Description",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Hide description", value: "no" },
|
||||
{ label: "Show description", value: "yes" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <CollectionView {...props} />,
|
||||
};
|
||||
}
|
||||
76
editor/components/commerce/collection.tsx
Normal file
76
editor/components/commerce/collection.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
showDescription: "yes" | "no";
|
||||
};
|
||||
|
||||
export function CollectionView({
|
||||
collection: selected,
|
||||
showDescription,
|
||||
}: CollectionProps) {
|
||||
const handle = selected?.handle ?? "";
|
||||
const { collection, loading } = useCollectionProducts(handle, { first: 24 });
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<section className="bg-background pb-24 pt-12 md:pt-20">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const products = (collection?.products as any[] | undefined) ?? [];
|
||||
const description = collection?.description ?? selected.description;
|
||||
|
||||
return (
|
||||
<section className="bg-background pb-24 pt-12 md:pt-20">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<header className="mx-auto mb-14 max-w-2xl text-center">
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Collection
|
||||
</p>
|
||||
<Typography variant="h1">
|
||||
{collection?.title ?? selected.title}
|
||||
</Typography>
|
||||
{showDescription === "yes" && description ? (
|
||||
<Typography variant="subtitle1" className="mt-4">
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<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) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
|
||||
{!loading && products.length === 0 ? (
|
||||
<div className="mx-auto mt-12 max-w-md text-center text-sm text-muted-foreground">
|
||||
This collection has no products yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
97
editor/components/commerce/collections.tsx
Normal file
97
editor/components/commerce/collections.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useCollections } from '@/editor/hooks/use-shopify-collections';
|
||||
import CollectionCard from './collection-card';
|
||||
|
||||
const Collections: React.FC = () => {
|
||||
const { collections, loading, error, refetch } = useCollections(20);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-video bg-gray-200"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-8 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<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">Could not load collections</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-5xl font-bold mb-8 font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-folder-line text-4xl text-gray-400 mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">
|
||||
No Collections Found
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Check back later or configure your Shopify store connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
{/* Collections Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collections;
|
||||
42
editor/components/commerce/featured-product.editor.tsx
Normal file
42
editor/components/commerce/featured-product.editor.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Star } from "lucide-react";
|
||||
import { FeaturedProductView, type FeaturedProductProps } from "@/editor/components/commerce/featured-product";
|
||||
|
||||
export function createFeaturedProductEditor(opts: {
|
||||
productField: any;
|
||||
}): ComponentConfig<FeaturedProductProps> {
|
||||
return {
|
||||
label: "Featured product",
|
||||
icon: <Star size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
tagline: "Featured",
|
||||
ctaLabel: "Add to bag",
|
||||
align: "left",
|
||||
tone: "default",
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Product", ...opts.productField },
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
align: {
|
||||
label: "Image alignment",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Image left", value: "left" },
|
||||
{ label: "Image right", value: "right" },
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Muted", value: "muted" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <FeaturedProductView {...props} />,
|
||||
};
|
||||
}
|
||||
129
editor/components/commerce/featured-product.tsx
Normal file
129
editor/components/commerce/featured-product.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Link } from "react-router";
|
||||
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;
|
||||
tagline: string;
|
||||
ctaLabel: string;
|
||||
align: "left" | "right";
|
||||
tone: "default" | "muted";
|
||||
};
|
||||
|
||||
export function FeaturedProductView({
|
||||
product: selected,
|
||||
tagline,
|
||||
ctaLabel,
|
||||
align,
|
||||
tone,
|
||||
}: FeaturedProductProps) {
|
||||
const { product: full, loading } = useProduct(selected?.handle ?? null);
|
||||
const product: any = full ?? selected;
|
||||
const cart = useShopifyCart();
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const image =
|
||||
product.images?.edges?.[0]?.node ?? (selected as any)?.featuredImage ?? null;
|
||||
const variant = product.variants?.edges?.[0]?.node;
|
||||
const price = product.priceRange?.minVariantPrice;
|
||||
const formatted = price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
tone === "muted"
|
||||
? "bg-muted/40 py-20 md:py-28"
|
||||
: "bg-background py-20 md:py-28"
|
||||
}
|
||||
>
|
||||
<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={align === "right" ? "md:order-2" : ""}>
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="aspect-[4/5] w-full rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-[4/5] w-full rounded-md bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-5">
|
||||
{tagline ? (
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{product.title}</Typography>
|
||||
{formatted ? (
|
||||
<Typography variant="subtitle1" className="text-foreground font-medium">
|
||||
{formatted}
|
||||
</Typography>
|
||||
) : null}
|
||||
{product.description ? (
|
||||
<Typography variant="body2" className="max-w-md text-muted-foreground">
|
||||
{product.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!variant) return;
|
||||
await cart.addItem(variant.id, 1);
|
||||
cart.openCart();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-full bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background hover:opacity-90"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
<Link
|
||||
to={`/products/${product.handle}`}
|
||||
className="inline-flex items-center justify-center rounded-full border border-foreground px-6 py-3 text-sm font-medium tracking-wide hover:opacity-80"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
72
editor/components/commerce/product-card.tsx
Normal file
72
editor/components/commerce/product-card.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
type ProductImage = { url: string; altText?: string };
|
||||
type ProductPrice = { amount: string; currencyCode: string };
|
||||
|
||||
export type ProductCardData = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
images?: { edges?: Array<{ node: ProductImage }> };
|
||||
priceRange?: { minVariantPrice?: ProductPrice };
|
||||
compareAtPriceRange?: { minVariantPrice?: ProductPrice };
|
||||
};
|
||||
|
||||
function format(price: ProductPrice) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount));
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
product,
|
||||
aspect = "portrait",
|
||||
}: {
|
||||
product: ProductCardData;
|
||||
aspect?: "portrait" | "square" | "landscape";
|
||||
}) {
|
||||
const image = product.images?.edges?.[0]?.node;
|
||||
const price = product.priceRange?.minVariantPrice;
|
||||
const compare = product.compareAtPriceRange?.minVariantPrice;
|
||||
const onSale =
|
||||
price && compare && parseFloat(compare.amount) > parseFloat(price.amount);
|
||||
|
||||
const aspectClass: Record<string, string> = {
|
||||
portrait: "aspect-[4/5]",
|
||||
square: "aspect-square",
|
||||
landscape: "aspect-[4/3]",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link to={`/products/${product.handle}`} className="group block">
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 flex items-start justify-between gap-3">
|
||||
<h3 className="text-sm font-medium tracking-tight">{product.title}</h3>
|
||||
{price ? (
|
||||
<div className="flex flex-col items-end text-sm">
|
||||
{onSale && compare ? (
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{format(compare)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-medium">{format(price)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProductCard;
|
||||
3
editor/components/commerce/product-detail.tsx
Normal file
3
editor/components/commerce/product-detail.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import ProductDetail from './product-detail/index.tsx';
|
||||
|
||||
export default ProductDetail;
|
||||
206
editor/components/commerce/product-detail/index.tsx
Normal file
206
editor/components/commerce/product-detail/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useProduct, type Product } from '@/editor/hooks/use-shopify-products';
|
||||
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
|
||||
import ProductDetailGallery from './product-detail-gallery';
|
||||
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,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/editor/components/ui/breadcrumb';
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
availableForSale: boolean;
|
||||
selectedOptions: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
image?: {
|
||||
url: string;
|
||||
altText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type { Product };
|
||||
|
||||
interface ProductDetailProps {
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) => {
|
||||
const handle = handleProp || '';
|
||||
const { addItem, openCart } = useShopifyCart();
|
||||
|
||||
const { product, loading, error } = useProduct(handle);
|
||||
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
const [addingToCart, setAddingToCart] = useState(false);
|
||||
|
||||
// Initialize variant when product loads
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
const firstVariant = product.variants.edges[0]?.node;
|
||||
if (firstVariant) {
|
||||
setSelectedVariant(firstVariant);
|
||||
|
||||
const initialOptions: Record<string, string> = {};
|
||||
firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => {
|
||||
initialOptions[option.name] = option.value;
|
||||
});
|
||||
setSelectedOptions(initialOptions);
|
||||
}
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const handleOptionChange = (optionName: string, value: string) => {
|
||||
const newOptions = { ...selectedOptions, [optionName]: value };
|
||||
setSelectedOptions(newOptions);
|
||||
|
||||
// Find matching variant
|
||||
const matchingVariant = product?.variants.edges.find(({ node }) => {
|
||||
return node.selectedOptions.every(option =>
|
||||
newOptions[option.name] === option.value
|
||||
);
|
||||
});
|
||||
|
||||
if (matchingVariant) {
|
||||
setSelectedVariant(matchingVariant.node);
|
||||
|
||||
// Update image if variant has an associated image
|
||||
if (matchingVariant.node.image && product) {
|
||||
const variantImageUrl = matchingVariant.node.image.url;
|
||||
const imageIndex = product.images.edges.findIndex(
|
||||
edge => edge.node.url === variantImageUrl
|
||||
);
|
||||
if (imageIndex !== -1) {
|
||||
setSelectedImageIndex(imageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
if (!selectedVariant || !product) return;
|
||||
|
||||
try {
|
||||
setAddingToCart(true);
|
||||
await addItem(selectedVariant.id, quantity);
|
||||
openCart();
|
||||
} catch (err) {
|
||||
console.error('Failed to add item to cart:', err);
|
||||
} finally {
|
||||
setAddingToCart(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/">Home</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/shop">Shop</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{product.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<ProductDetailGallery
|
||||
images={product.images.edges.map(edge => edge.node)}
|
||||
selectedImageIndex={selectedImageIndex}
|
||||
onImageSelect={setSelectedImageIndex}
|
||||
/>
|
||||
<ProductDetailInfo
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
selectedOptions={selectedOptions}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
handleAddToCart={handleAddToCart}
|
||||
onOptionChange={handleOptionChange}
|
||||
loading={addingToCart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductRecommendations productId={product.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/editor/components/ui/button';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
interface ProductDetailGalleryProps {
|
||||
images: ProductImage[];
|
||||
selectedImageIndex?: number;
|
||||
onImageSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
images,
|
||||
selectedImageIndex = 0,
|
||||
onImageSelect
|
||||
}) => {
|
||||
const selectedImage = selectedImageIndex;
|
||||
const setSelectedImage = onImageSelect || (() => {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main Image */}
|
||||
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden mb-4">
|
||||
{images.length > 0 ? (
|
||||
<img
|
||||
src={images[selectedImage].url}
|
||||
alt={images[selectedImage].altText || 'Product image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<i className="ri-image-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
className={`aspect-square rounded-lg overflow-hidden border-2 transition-colors ${
|
||||
selectedImage === index
|
||||
? 'border-black'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product thumbnail'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailGallery;
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { Product, ProductVariant } from './index.tsx';
|
||||
import { Button } from '@/editor/components/ui/button';
|
||||
import { Badge } from '@/editor/components/ui/badge';
|
||||
import { Spinner } from '@/editor/components/ui/spinner';
|
||||
|
||||
interface ProductDetailInfoProps {
|
||||
product: Product;
|
||||
selectedVariant: ProductVariant | null;
|
||||
selectedOptions: Record<string, string>;
|
||||
quantity: number;
|
||||
setQuantity: (quantity: number) => void;
|
||||
handleAddToCart: () => void;
|
||||
onOptionChange: (optionName: string, value: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
product,
|
||||
selectedVariant,
|
||||
selectedOptions,
|
||||
quantity,
|
||||
setQuantity,
|
||||
handleAddToCart,
|
||||
onOptionChange,
|
||||
loading = false,
|
||||
}) => {
|
||||
const formatPrice = (price: { amount: string; currencyCode: string }) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(parseFloat(price.amount));
|
||||
};
|
||||
|
||||
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
|
||||
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
|
||||
const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4 font-heading">
|
||||
{product.title}
|
||||
</h1>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<>
|
||||
<span className="text-xl text-gray-500 line-through">
|
||||
{formatPrice(compareAtPrice)}
|
||||
</span>
|
||||
<Badge variant="destructive">
|
||||
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div className="text-gray-600 mb-8 text-lg leading-relaxed">
|
||||
{product.descriptionHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
|
||||
) : (
|
||||
<p>{product.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Options */}
|
||||
{product.options.map(option => (
|
||||
<div key={option.id} className="mb-6">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
{option.name}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{option.values.map(value => (
|
||||
<Button
|
||||
key={value}
|
||||
onClick={() => onOptionChange(option.name, value)}
|
||||
variant={selectedOptions[option.name] === value ? 'default' : 'outline'}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center border border-gray-300 rounded-lg w-fit">
|
||||
<Button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<i className="ri-subtract-line"></i>
|
||||
</Button>
|
||||
<span className="w-10 text-center font-semibold">{quantity}</span>
|
||||
<Button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
>
|
||||
<i className="ri-add-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!selectedVariant?.availableForSale || loading}
|
||||
size="lg"
|
||||
className="w-full text-lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Adding...</span>
|
||||
</span>
|
||||
) : selectedVariant?.availableForSale ? (
|
||||
'Add to Cart'
|
||||
) : (
|
||||
'Out of Stock'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-truck-line"></i>
|
||||
<span>Free shipping on orders over $100</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-arrow-go-back-line"></i>
|
||||
<span>30-day return policy</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-secure-payment-line"></i>
|
||||
<span>Secure payment</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailInfo;
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useProductRecommendations } from '@/editor/hooks/use-shopify-products';
|
||||
import ProductCard from '../product-card';
|
||||
|
||||
interface ProductRecommendationsProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({ productId }) => {
|
||||
const { recommendations, loading, error } = useProductRecommendations(productId);
|
||||
|
||||
// Don't show section if we're not loading and have no recommendations
|
||||
if (!loading && (!recommendations || recommendations.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 text-gray-900 font-heading">
|
||||
You Might Also Like
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).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>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Recommendations could not be loaded</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{recommendations.slice(0, 4).map((recommendedProduct) => (
|
||||
<ProductCard
|
||||
key={recommendedProduct.id}
|
||||
product={recommendedProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductRecommendations;
|
||||
16
editor/components/commerce/product-details.editor.tsx
Normal file
16
editor/components/commerce/product-details.editor.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Package } from "lucide-react";
|
||||
import { ProductDetailsView, type ProductDetailsProps } from "@/editor/components/commerce/product-details";
|
||||
|
||||
export function createProductDetailsEditor(opts: {
|
||||
productField: any;
|
||||
}): ComponentConfig<ProductDetailsProps> {
|
||||
return {
|
||||
label: "Product details",
|
||||
icon: <Package size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: { product: null },
|
||||
fields: { product: { label: "Product", ...opts.productField } },
|
||||
render: (props) => <ProductDetailsView {...props} />,
|
||||
};
|
||||
}
|
||||
212
editor/components/commerce/product-details.tsx
Normal file
212
editor/components/commerce/product-details.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
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 { cn } from "@/editor/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export type ProductDetailsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
};
|
||||
|
||||
export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
|
||||
const { handle: paramHandle } = useParams<{ handle?: string }>();
|
||||
const handle = selected?.handle ?? paramHandle ?? null;
|
||||
const { product, loading } = useProduct(handle);
|
||||
const cart = useShopifyCart();
|
||||
const [activeImage, setActiveImage] = useState(0);
|
||||
const [variant, setVariant] = useState<any>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (product?.variants?.edges?.length) {
|
||||
setVariant(product.variants.edges[0].node);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (!handle || loading || !product) {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const images = product.images?.edges?.map((e: any) => e.node) ?? [];
|
||||
const main = images[activeImage];
|
||||
const price = variant?.price ?? product.priceRange?.minVariantPrice;
|
||||
const formatted = price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount))
|
||||
: null;
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!variant) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await cart.addItem(variant.id, quantity);
|
||||
cart.openCart();
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="aspect-[4/5] w-full overflow-hidden rounded-md bg-muted">
|
||||
{main ? (
|
||||
<img
|
||||
src={main.url}
|
||||
alt={main.altText || product.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{images.length > 1 ? (
|
||||
<div className="flex gap-3 overflow-x-auto p-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{images.map((img: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveImage(i)}
|
||||
className={cn(
|
||||
"aspect-square w-20 flex-shrink-0 overflow-hidden rounded-md transition-opacity",
|
||||
i === activeImage
|
||||
? "ring-2 ring-foreground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Typography variant="h2" as="h1">
|
||||
{product.title}
|
||||
</Typography>
|
||||
{formatted ? (
|
||||
<Typography variant="subtitle1" className="mt-3 text-foreground">
|
||||
{formatted}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(product.options ?? []).map((opt: any) => (
|
||||
<div key={opt.id ?? opt.name}>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{opt.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opt.values.map((val: string) => {
|
||||
const matching = product.variants.edges.find((e: any) =>
|
||||
e.node.selectedOptions?.some(
|
||||
(o: any) => o.name === opt.name && o.value === val,
|
||||
),
|
||||
);
|
||||
const selected = variant?.selectedOptions?.some(
|
||||
(o: any) => o.name === opt.name && o.value === val,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => matching && setVariant(matching.node)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-full border px-4 py-2 text-sm transition-colors",
|
||||
selected
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border hover:border-foreground",
|
||||
)}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<div className="flex items-center gap-3 rounded-full border border-border px-4 py-2">
|
||||
<button
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="text-base hover:opacity-60"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
className="text-base hover:opacity-60"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
disabled={!variant || adding}
|
||||
className="flex-1 rounded-full bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{adding ? "Adding…" : "Add to bag"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{product.description ? (
|
||||
<div className="border-t border-border pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Details
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-foreground/80">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
editor/components/commerce/products-carousel.editor.tsx
Normal file
42
editor/components/commerce/products-carousel.editor.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { GalleryHorizontalEnd } from "lucide-react";
|
||||
import { ProductsCarousel, type ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
|
||||
|
||||
export function createProductsCarouselEditor(opts: {
|
||||
collectionField: any;
|
||||
}): ComponentConfig<ProductsCarouselProps> {
|
||||
return {
|
||||
label: "Products carousel",
|
||||
icon: <GalleryHorizontalEnd size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
tagline: "New",
|
||||
heading: "Just dropped",
|
||||
subheading: "Fresh additions to the lineup.",
|
||||
limit: 12,
|
||||
slidesPerView: "4",
|
||||
ctaLabel: "Shop new",
|
||||
ctaHref: "",
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", ...opts.collectionField },
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 4, max: 24 },
|
||||
slidesPerView: {
|
||||
label: "Slides per view",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "2 per view", value: "2" },
|
||||
{ label: "3 per view", value: "3" },
|
||||
{ label: "4 per view", value: "4" },
|
||||
],
|
||||
},
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
},
|
||||
render: (props) => <ProductsCarousel {...props} />,
|
||||
};
|
||||
}
|
||||
126
editor/components/commerce/products-carousel.tsx
Normal file
126
editor/components/commerce/products-carousel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
||||
import { getProducts } from "@/editor/hooks/use-shopify-products";
|
||||
import { getCollectionProducts } from "@/editor/hooks/use-shopify-collections";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/editor/components/ui/carousel";
|
||||
|
||||
export type ProductsCarouselProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
limit: number;
|
||||
slidesPerView: "2" | "3" | "4";
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const basisClass: Record<ProductsCarouselProps["slidesPerView"], string> = {
|
||||
"2": "md:basis-1/2",
|
||||
"3": "md:basis-1/3",
|
||||
"4": "md:basis-1/4",
|
||||
};
|
||||
|
||||
export function ProductsCarousel({
|
||||
collection,
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
limit,
|
||||
slidesPerView,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
}: ProductsCarouselProps) {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
if (collection?.handle) {
|
||||
const data = await getCollectionProducts(collection.handle, {
|
||||
first: limit,
|
||||
});
|
||||
if (!cancelled) setProducts(data?.products ?? []);
|
||||
} else {
|
||||
const data = await getProducts({
|
||||
first: limit,
|
||||
sortKey: "CREATED_AT",
|
||||
reverse: true,
|
||||
});
|
||||
if (!cancelled) setProducts(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProducts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [collection?.handle, limit]);
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<div className="mb-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<div className="max-w-xl">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
to={
|
||||
ctaHref ||
|
||||
(collection?.handle ? `/collections/${collection.handle}` : "/collections")
|
||||
}
|
||||
className="text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Carousel opts={{ align: "start", loop: true }}>
|
||||
<CarouselContent className="-ml-6">
|
||||
{(products.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }))
|
||||
: products
|
||||
).map((p: any) => (
|
||||
<CarouselItem
|
||||
key={p.id}
|
||||
className={`pl-6 basis-3/4 sm:basis-1/2 ${basisClass[slidesPerView]}`}
|
||||
>
|
||||
{products.length === 0 ? (
|
||||
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<ProductCard product={p} />
|
||||
)}
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="hidden md:inline-flex" />
|
||||
<CarouselNext className="hidden md:inline-flex" />
|
||||
</Carousel>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
41
editor/components/commerce/products-grid.editor.tsx
Normal file
41
editor/components/commerce/products-grid.editor.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { ProductsGrid, type ProductsGridProps } from "@/editor/components/commerce/products-grid";
|
||||
|
||||
export function createProductsGridEditor(opts: {
|
||||
collectionField: any;
|
||||
}): ComponentConfig<ProductsGridProps> {
|
||||
return {
|
||||
label: "Products grid",
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
tagline: "Shop",
|
||||
heading: "Latest arrivals",
|
||||
subheading: "New pieces, fresh in this season.",
|
||||
columns: "4",
|
||||
limit: 8,
|
||||
ctaLabel: "View all",
|
||||
ctaHref: "",
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", ...opts.collectionField },
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "3 columns", value: "3" },
|
||||
{ label: "4 columns", value: "4" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 24 },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
},
|
||||
render: (props) => <ProductsGrid {...props} />,
|
||||
};
|
||||
}
|
||||
100
editor/components/commerce/products-grid.tsx
Normal file
100
editor/components/commerce/products-grid.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
||||
import { getProducts } from "@/editor/hooks/use-shopify-products";
|
||||
import { getCollectionProducts } from "@/editor/hooks/use-shopify-collections";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type ProductsGridProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
columns: "3" | "4";
|
||||
limit: number;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const colClass: Record<ProductsGridProps["columns"], string> = {
|
||||
"3": "grid-cols-2 md:grid-cols-3",
|
||||
"4": "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
|
||||
};
|
||||
|
||||
export function ProductsGrid({
|
||||
collection,
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
columns,
|
||||
limit,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
}: ProductsGridProps) {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
if (collection?.handle) {
|
||||
const data = await getCollectionProducts(collection.handle, {
|
||||
first: limit,
|
||||
});
|
||||
if (!cancelled) setProducts(data?.products ?? []);
|
||||
} else {
|
||||
const data = await getProducts({ first: limit, sortKey: "BEST_SELLING" });
|
||||
if (!cancelled) setProducts(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProducts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [collection?.handle, limit]);
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<div className="mb-12 flex flex-col items-end justify-between gap-6 md:flex-row md:items-end">
|
||||
<div className="max-w-xl">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
to={ctaHref || (collection?.handle ? `/collections/${collection.handle}` : "/collections")}
|
||||
className="text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-x-6 gap-y-12 ${colClass[columns]}`}>
|
||||
{products.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
|
||||
/>
|
||||
))
|
||||
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
233
editor/components/commerce/products.tsx
Normal file
233
editor/components/commerce/products.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ProductCard from './product-card';
|
||||
import { getProducts } from '@/editor/hooks/use-shopify-products';
|
||||
import { Button } from '@/editor/components/ui/button';
|
||||
import { Spinner } from '@/editor/components/ui/spinner';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
interface ProductPrice {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: ProductPrice;
|
||||
availableForSale: boolean;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
handle: string;
|
||||
images: {
|
||||
edges: Array<{
|
||||
node: ProductImage;
|
||||
}>;
|
||||
};
|
||||
priceRange: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
compareAtPriceRange?: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: ProductVariant;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductsProps {
|
||||
title?: string;
|
||||
limit?: number;
|
||||
showLoadMore?: boolean;
|
||||
}
|
||||
|
||||
const Products: React.FC<ProductsProps> = ({
|
||||
title = "Our Products",
|
||||
limit = 12,
|
||||
showLoadMore = true
|
||||
}) => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMoreProducts, setHasMoreProducts] = useState(true);
|
||||
|
||||
const fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => {
|
||||
try {
|
||||
if (loadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const newProducts = await getProducts({
|
||||
first: limit,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out products that already exist
|
||||
const existingIds = new Set(currentProducts.map(p => p.id));
|
||||
const uniqueNewProducts = newProducts.filter(p => !existingIds.has(p.id));
|
||||
|
||||
if (uniqueNewProducts.length === 0) {
|
||||
setHasMoreProducts(false);
|
||||
} else {
|
||||
setProducts(prev => [...prev, ...uniqueNewProducts]);
|
||||
}
|
||||
} else {
|
||||
setProducts(newProducts);
|
||||
setHasMoreProducts(newProducts.length === limit);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching products:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load products');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [limit]);
|
||||
|
||||
const handleAddToCart = async (product: Product) => {
|
||||
// Here you would typically integrate with cart functionality
|
||||
console.log('Adding to cart:', product);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!loadingMore && hasMoreProducts) {
|
||||
fetchProducts(products, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<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">Could not load products</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fetchProducts()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-4xl font-bold mb-8 font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-gray-400 mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">
|
||||
No Products Found
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Check back later or configure your Shopify store connection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-gray-900 font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-12">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{showLoadMore && hasMoreProducts && (
|
||||
<div className="text-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
size="lg"
|
||||
className="font-heading"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<span className="flex items-center space-x-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Load More Products'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
26
editor/components/commerce/recommended-products.editor.tsx
Normal file
26
editor/components/commerce/recommended-products.editor.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { RecommendedProductsView, type RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
|
||||
|
||||
export function createRecommendedProductsEditor(opts: {
|
||||
productField: any;
|
||||
}): ComponentConfig<RecommendedProductsProps> {
|
||||
return {
|
||||
label: "Recommended products",
|
||||
icon: <Sparkles size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
tagline: "You may also like",
|
||||
heading: "More to explore",
|
||||
limit: 4,
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Source product", ...opts.productField },
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 8 },
|
||||
},
|
||||
render: (props) => <RecommendedProductsView {...props} />,
|
||||
};
|
||||
}
|
||||
67
editor/components/commerce/recommended-products.tsx
Normal file
67
editor/components/commerce/recommended-products.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import {
|
||||
useProduct,
|
||||
useProductRecommendations,
|
||||
} 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;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export function RecommendedProductsView({
|
||||
product: selected,
|
||||
tagline,
|
||||
heading,
|
||||
limit,
|
||||
}: RecommendedProductsProps) {
|
||||
const { product } = useProduct(selected?.handle ?? null);
|
||||
const { recommendations } = useProductRecommendations(product?.id ?? null);
|
||||
const items = (recommendations ?? []).slice(0, limit);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<div className="mb-12 max-w-xl">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h3">{heading}</Typography>
|
||||
</div>
|
||||
|
||||
<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) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
24
editor/components/commerce/shop-footer.tsx
Normal file
24
editor/components/commerce/shop-footer.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-black text-white py-12">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h3
|
||||
className="text-2xl font-bold mb-4"
|
||||
style={{fontFamily: 'Space Grotesk, sans-serif'}}
|
||||
>
|
||||
Store
|
||||
</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Your premium shopping destination
|
||||
</p>
|
||||
<div className="mt-8 pt-8 border-t border-gray-800 text-gray-400">
|
||||
<p>© 2025 Store. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
68
editor/components/commerce/shop-header.tsx
Normal file
68
editor/components/commerce/shop-header.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useShopifyCart } from '@/editor/hooks/use-shopify-cart';
|
||||
import config from '@/editor/lib/config.json';
|
||||
|
||||
const CartIcon: React.FC = () => {
|
||||
const { toggleCart, itemCount } = useShopifyCart();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleCart}
|
||||
className="relative p-1 text-black hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<i className="ri-shopping-cart-line text-xl"></i>
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-black text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center font-semibold">
|
||||
{itemCount > 99 ? '99+' : itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const Header: React.FC = () => {
|
||||
return (
|
||||
<nav className="bg-white shadow-sm sticky top-0 z-30 h-14">
|
||||
<div className="container mx-auto px-4 h-full">
|
||||
<div className="flex justify-between items-center h-full">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="text-lg font-bold text-black font-heading">
|
||||
{config.brand.logo.url ? (
|
||||
<img
|
||||
src={config.brand.logo.url}
|
||||
alt={config.brand.logo.alt || 'Store'}
|
||||
className="h-8"
|
||||
/>
|
||||
) : (
|
||||
'Store'
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/collections"
|
||||
className="text-sm text-black hover:text-gray-600 font-medium transition-colors"
|
||||
>
|
||||
Collections
|
||||
</Link>
|
||||
|
||||
{/* Cart Icon */}
|
||||
<CartIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
51
editor/components/cta/cta.editor.tsx
Normal file
51
editor/components/cta/cta.editor.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { CTA, type CTAProps } from "@/editor/components/cta/cta";
|
||||
|
||||
export const ctaEditor: ComponentConfig<CTAProps> = {
|
||||
label: "Call to action",
|
||||
icon: <Megaphone size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "",
|
||||
heading: "Designed once. Worn for years.",
|
||||
subheading:
|
||||
"Join 40,000 people building a wardrobe they actually reach for.",
|
||||
primaryCta: { label: "Shop now", href: "/collections" },
|
||||
secondaryCta: { label: "Read our story", href: "/about" },
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=2400&q=80",
|
||||
align: "center",
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "textarea", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
primaryCta: {
|
||||
label: "Primary CTA",
|
||||
type: "object",
|
||||
objectFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
secondaryCta: {
|
||||
label: "Secondary CTA",
|
||||
type: "object",
|
||||
objectFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
imageUrl: { label: "Background image URL", type: "text" },
|
||||
align: {
|
||||
label: "Alignment",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Left", value: "left" },
|
||||
{ label: "Center", value: "center" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <CTA {...props} />,
|
||||
};
|
||||
83
editor/components/cta/cta.tsx
Normal file
83
editor/components/cta/cta.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type CTAProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
primaryCta: { label: string; href: string };
|
||||
secondaryCta: { label: string; href: string };
|
||||
imageUrl: string;
|
||||
align: "left" | "center";
|
||||
};
|
||||
|
||||
export function CTA({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
imageUrl,
|
||||
align,
|
||||
}: CTAProps) {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-24 md:py-32">
|
||||
<div className="absolute inset-0 -z-10">
|
||||
{imageUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/45" />
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full w-full bg-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"container mx-auto flex max-w-4xl flex-col px-6 text-white",
|
||||
align === "center" ? "items-center text-center" : "items-start",
|
||||
)}
|
||||
>
|
||||
{tagline ? (
|
||||
<p className="mb-4 text-xs uppercase tracking-[0.2em] text-white/80">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-5 max-w-xl text-white/80">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-10 flex flex-wrap gap-3",
|
||||
align === "center" && "justify-center",
|
||||
)}
|
||||
>
|
||||
{primaryCta?.label ? (
|
||||
<Link
|
||||
to={primaryCta.href || "#"}
|
||||
className="inline-flex items-center justify-center rounded-full bg-white px-6 py-3 text-sm font-medium tracking-wide text-black hover:opacity-90"
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Link>
|
||||
) : null}
|
||||
{secondaryCta?.label ? (
|
||||
<Link
|
||||
to={secondaryCta.href || "#"}
|
||||
className="inline-flex items-center justify-center rounded-full border border-white px-6 py-3 text-sm font-medium tracking-wide text-white hover:bg-white/10"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
52
editor/components/faq/faq.editor.tsx
Normal file
52
editor/components/faq/faq.editor.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { FAQ, type FAQProps } from "@/editor/components/faq/faq";
|
||||
|
||||
export const faqEditor: ComponentConfig<FAQProps> = {
|
||||
label: "FAQ",
|
||||
icon: <HelpCircle size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Help",
|
||||
heading: "Common questions",
|
||||
subheading: "",
|
||||
items: [
|
||||
{
|
||||
question: "What's your return policy?",
|
||||
answer:
|
||||
"Free returns within 30 days of delivery. Items should be unworn with original tags attached.",
|
||||
},
|
||||
{
|
||||
question: "Where do you ship?",
|
||||
answer:
|
||||
"We ship worldwide. Free standard shipping on orders over $150 in the US, $250 international.",
|
||||
},
|
||||
{
|
||||
question: "How are your products made?",
|
||||
answer:
|
||||
"In small batches at family-run mills in Portugal, Italy, and Japan. Every piece is sampled and approved by our team.",
|
||||
},
|
||||
{
|
||||
question: "How do I care for my pieces?",
|
||||
answer:
|
||||
"Cold wash, lay flat to dry, iron when damp. Care details are on every product page and on the inner label.",
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
items: {
|
||||
label: "Items",
|
||||
type: "array",
|
||||
defaultItemProps: { question: "", answer: "" },
|
||||
getItemSummary: (it) => it?.question || "Question",
|
||||
arrayFields: {
|
||||
question: { label: "Question", type: "text", contentEditable: true },
|
||||
answer: { label: "Answer", type: "textarea", contentEditable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <FAQ {...props} />,
|
||||
};
|
||||
61
editor/components/faq/faq.tsx
Normal file
61
editor/components/faq/faq.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Minus } from "lucide-react";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type FAQProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
items: Array<{ question: string; answer: string }>;
|
||||
};
|
||||
|
||||
export function FAQ({ tagline, heading, subheading, items }: FAQProps) {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-3xl px-6">
|
||||
<div className="mb-12 text-center">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
{items.map((item, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<div key={i}>
|
||||
<button
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
className="flex w-full items-center justify-between py-6 text-left"
|
||||
>
|
||||
<span className="text-base font-medium tracking-tight md:text-lg">
|
||||
{item.question}
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<Minus size={18} strokeWidth={1.5} className="flex-shrink-0" />
|
||||
) : (
|
||||
<Plus size={18} strokeWidth={1.5} className="flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<p className="pb-6 pr-8 text-sm leading-relaxed text-muted-foreground md:text-base">
|
||||
{item.answer}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
editor/components/features/features.editor.tsx
Normal file
54
editor/components/features/features.editor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Features, type FeaturesProps } from "@/editor/components/features/features";
|
||||
|
||||
export const featuresEditor: ComponentConfig<FeaturesProps> = {
|
||||
label: "Features",
|
||||
icon: <Sparkles size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Why us",
|
||||
heading: "Built with intention",
|
||||
subheading: "A small set of values that shape every piece we make.",
|
||||
columns: "3",
|
||||
items: [
|
||||
{
|
||||
title: "Natural fibers",
|
||||
body: "Linen, organic cotton, and merino — sourced from mills with traceable supply chains.",
|
||||
},
|
||||
{
|
||||
title: "Small batches",
|
||||
body: "Made in considered quantities so nothing goes to waste — and nothing gets discounted into the bin.",
|
||||
},
|
||||
{
|
||||
title: "Built to last",
|
||||
body: "Reinforced seams, double-stitched edges, and finishes that age into something better.",
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "2 columns", value: "2" },
|
||||
{ label: "3 columns", value: "3" },
|
||||
{ label: "4 columns", value: "4" },
|
||||
],
|
||||
},
|
||||
items: {
|
||||
label: "Items",
|
||||
type: "array",
|
||||
defaultItemProps: { title: "Feature", body: "Description." },
|
||||
getItemSummary: (it) => it?.title || "Feature",
|
||||
arrayFields: {
|
||||
title: { label: "Title", type: "text", contentEditable: true },
|
||||
body: { label: "Body", type: "textarea", contentEditable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <Features {...props} />,
|
||||
};
|
||||
51
editor/components/features/features.tsx
Normal file
51
editor/components/features/features.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type FeaturesProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
columns: "2" | "3" | "4";
|
||||
items: Array<{ title: string; body: string }>;
|
||||
};
|
||||
|
||||
const colClass: Record<FeaturesProps["columns"], string> = {
|
||||
"2": "md:grid-cols-2",
|
||||
"3": "md:grid-cols-3",
|
||||
"4": "md:grid-cols-2 lg:grid-cols-4",
|
||||
};
|
||||
|
||||
export function Features({ tagline, heading, subheading, columns, items }: FeaturesProps) {
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
<div className="mx-auto mb-16 max-w-2xl text-center">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-1 gap-x-10 gap-y-12 ${colClass[columns]}`}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="border-t border-border pt-6">
|
||||
<p className="mb-3 text-xs tracking-[0.18em] text-muted-foreground">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</p>
|
||||
<Typography variant="h5">{item.title}</Typography>
|
||||
<Typography variant="body2" className="mt-3 text-muted-foreground">
|
||||
{item.body}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
102
editor/components/footer/footer.editor.tsx
Normal file
102
editor/components/footer/footer.editor.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { Footer, type FooterProps } from "@/editor/components/footer/footer";
|
||||
|
||||
export const footerEditor: ComponentConfig<FooterProps> = {
|
||||
label: "Footer",
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: "footer",
|
||||
global: true,
|
||||
defaultProps: {
|
||||
brand: "Maison",
|
||||
tagline:
|
||||
"Considered essentials, made in small batches and built to last beyond the season.",
|
||||
columns: [
|
||||
{
|
||||
title: "Shop",
|
||||
links: [
|
||||
{ label: "All", href: "/collections" },
|
||||
{ label: "New", href: "/collections/new" },
|
||||
{ label: "Best sellers", href: "/collections/best" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
links: [
|
||||
{ label: "Our story", href: "/about" },
|
||||
{ label: "Materials", href: "/materials" },
|
||||
{ label: "Journal", href: "/journal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
links: [
|
||||
{ label: "Shipping", href: "/help/shipping" },
|
||||
{ label: "Returns", href: "/help/returns" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
],
|
||||
social: [
|
||||
{ label: "Instagram", href: "#" },
|
||||
{ label: "Pinterest", href: "#" },
|
||||
{ label: "TikTok", href: "#" },
|
||||
],
|
||||
showNewsletter: "yes",
|
||||
newsletterHeading: "Stay in touch",
|
||||
newsletterEndpoint: "",
|
||||
copyright: "© 2026 Maison. All rights reserved.",
|
||||
},
|
||||
fields: {
|
||||
brand: { label: "Brand", type: "text", contentEditable: true },
|
||||
tagline: { label: "Tagline", type: "textarea", contentEditable: true },
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "array",
|
||||
defaultItemProps: { title: "Column", links: [] },
|
||||
getItemSummary: (it) => it?.title || "Column",
|
||||
arrayFields: {
|
||||
title: { label: "Title", type: "text", contentEditable: true },
|
||||
links: {
|
||||
label: "Links",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "Link", href: "/" },
|
||||
getItemSummary: (it) => it?.label || "Link",
|
||||
arrayFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
social: {
|
||||
label: "Social links",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "Instagram", href: "#" },
|
||||
getItemSummary: (it) => it?.label || "Social",
|
||||
arrayFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
showNewsletter: {
|
||||
label: "Newsletter form",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
newsletterHeading: { label: "Newsletter heading", type: "text", contentEditable: true },
|
||||
newsletterEndpoint: { label: "Newsletter endpoint", type: "text" },
|
||||
copyright: { label: "Copyright", type: "text", contentEditable: true },
|
||||
},
|
||||
render: (props) => <Footer {...props} />,
|
||||
};
|
||||
129
editor/components/footer/footer.tsx
Normal file
129
editor/components/footer/footer.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type FooterProps = {
|
||||
brand: string;
|
||||
tagline: string;
|
||||
columns: Array<{
|
||||
title: string;
|
||||
links: Array<{ label: string; href: string }>;
|
||||
}>;
|
||||
social: Array<{ label: string; href: string }>;
|
||||
showNewsletter: "yes" | "no";
|
||||
newsletterHeading: string;
|
||||
newsletterEndpoint: string;
|
||||
copyright: string;
|
||||
};
|
||||
|
||||
export function Footer({
|
||||
brand,
|
||||
tagline,
|
||||
columns,
|
||||
social,
|
||||
showNewsletter,
|
||||
newsletterHeading,
|
||||
newsletterEndpoint,
|
||||
copyright,
|
||||
}: FooterProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email) return;
|
||||
if (newsletterEndpoint) {
|
||||
try {
|
||||
await fetch(newsletterEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border bg-background">
|
||||
<div className="container mx-auto max-w-7xl px-6 py-20 md:py-24">
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-12">
|
||||
<div className="md:col-span-4">
|
||||
<Typography variant="h5" as="p">
|
||||
{brand}
|
||||
</Typography>
|
||||
{tagline ? (
|
||||
<Typography variant="body2" className="mt-3 max-w-sm text-muted-foreground">
|
||||
{tagline}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{showNewsletter === "yes" ? (
|
||||
<form onSubmit={submit} className="mt-8 max-w-sm">
|
||||
<p className="text-sm font-medium">{newsletterHeading}</p>
|
||||
{submitted ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Thanks — we'll be in touch.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex border-b border-border focus-within:border-foreground">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
Subscribe →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 md:col-span-8 md:grid-cols-4">
|
||||
{columns.map((col, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{col.title}
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{col.links.map((l, j) => (
|
||||
<li key={j}>
|
||||
<Link
|
||||
to={l.href}
|
||||
className="text-sm text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 md:flex-row md:items-center">
|
||||
<p className="text-xs text-muted-foreground">{copyright}</p>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{social.map((s, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={s.href}
|
||||
className="text-xs uppercase tracking-[0.18em] text-foreground/70 hover:text-foreground"
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
70
editor/components/hero/hero.editor.tsx
Normal file
70
editor/components/hero/hero.editor.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutTemplate } from "lucide-react";
|
||||
import { Hero, type HeroProps } from "@/editor/components/hero/hero";
|
||||
|
||||
export const heroEditor: ComponentConfig<HeroProps> = {
|
||||
label: "Hero",
|
||||
icon: <LayoutTemplate size={16} />,
|
||||
category: "hero",
|
||||
defaultProps: {
|
||||
tagline: "Spring 2026",
|
||||
heading: "Made for the way you move",
|
||||
subheading:
|
||||
"A considered wardrobe of essentials, cut from natural fibers and designed to last.",
|
||||
primaryCta: { label: "Shop the collection", href: "/collections" },
|
||||
secondaryCta: { label: "Our story", href: "/about" },
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80",
|
||||
align: "left",
|
||||
height: "lg",
|
||||
tone: "dark",
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "textarea", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
primaryCta: {
|
||||
label: "Primary CTA",
|
||||
type: "object",
|
||||
objectFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
secondaryCta: {
|
||||
label: "Secondary CTA",
|
||||
type: "object",
|
||||
objectFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
imageUrl: { label: "Background image URL", type: "text" },
|
||||
align: {
|
||||
label: "Alignment",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Left", value: "left" },
|
||||
{ label: "Center", value: "center" },
|
||||
],
|
||||
},
|
||||
height: {
|
||||
label: "Height",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Medium", value: "md" },
|
||||
{ label: "Large", value: "lg" },
|
||||
{ label: "Full", value: "full" },
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <Hero {...props} />,
|
||||
};
|
||||
122
editor/components/hero/hero.tsx
Normal file
122
editor/components/hero/hero.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type HeroProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
primaryCta: { label: string; href: string };
|
||||
secondaryCta: { label: string; href: string };
|
||||
imageUrl: string;
|
||||
align: "left" | "center";
|
||||
height: "md" | "lg" | "full";
|
||||
tone: "light" | "dark";
|
||||
};
|
||||
|
||||
const heightClass: Record<HeroProps["height"], string> = {
|
||||
md: "min-h-[60vh]",
|
||||
lg: "min-h-[80vh]",
|
||||
full: "min-h-screen",
|
||||
};
|
||||
|
||||
export function Hero({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
imageUrl,
|
||||
align,
|
||||
height,
|
||||
tone,
|
||||
}: HeroProps) {
|
||||
const isDark = tone === "dark";
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"relative flex w-full items-end overflow-hidden isolate",
|
||||
heightClass[height],
|
||||
)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 z-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="absolute inset-0 z-[1]"
|
||||
style={{
|
||||
background: isDark
|
||||
? "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.55) 100%)"
|
||||
: "linear-gradient(180deg, rgba(255,255,255,0.0) 30%, rgba(255,255,255,0.85) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"container relative z-[2] mx-auto flex max-w-7xl flex-col px-6 py-20 md:py-28",
|
||||
align === "center" ? "items-center text-center" : "items-start",
|
||||
isDark ? "text-white" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{tagline ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mb-5 text-xs uppercase tracking-[0.2em]",
|
||||
isDark ? "text-white/80" : "text-foreground/70",
|
||||
)}
|
||||
>
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h1" className="max-w-3xl">
|
||||
{heading}
|
||||
</Typography>
|
||||
{subheading ? (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className={cn(
|
||||
"mt-6 max-w-xl",
|
||||
isDark ? "text-white/80" : "text-foreground/70",
|
||||
)}
|
||||
>
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-10 flex flex-wrap gap-3",
|
||||
align === "center" && "justify-center",
|
||||
)}
|
||||
>
|
||||
{primaryCta?.label ? (
|
||||
<Link
|
||||
to={primaryCta.href || "#"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-90",
|
||||
isDark ? "bg-white text-black" : "bg-foreground text-background",
|
||||
)}
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Link>
|
||||
) : null}
|
||||
{secondaryCta?.label ? (
|
||||
<Link
|
||||
to={secondaryCta.href || "#"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
|
||||
isDark ? "border-white text-white" : "border-foreground text-foreground",
|
||||
)}
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
editor/components/landing/banner.editor.tsx
Normal file
30
editor/components/landing/banner.editor.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Banner, type BannerProps } from "@/editor/components/landing/banner";
|
||||
|
||||
export const bannerEditor: ComponentConfig<BannerProps> = {
|
||||
label: "Announcement bar",
|
||||
icon: <Megaphone size={16} />,
|
||||
category: "hero",
|
||||
defaultProps: {
|
||||
text: "Free shipping on orders over $150",
|
||||
ctaLabel: "Shop new",
|
||||
ctaHref: "/collections/new",
|
||||
tone: "default",
|
||||
},
|
||||
fields: {
|
||||
text: { label: "Text", type: "text", contentEditable: true },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Default (dark)", value: "default" },
|
||||
{ label: "Inverse (light)", value: "inverse" },
|
||||
{ label: "Muted", value: "muted" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <Banner {...props} />,
|
||||
};
|
||||
32
editor/components/landing/banner.tsx
Normal file
32
editor/components/landing/banner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
export type BannerProps = {
|
||||
text: string;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
tone: "default" | "inverse" | "muted";
|
||||
};
|
||||
|
||||
export function Banner({ text, ctaLabel, ctaHref, tone }: BannerProps) {
|
||||
const toneClass: Record<BannerProps["tone"], string> = {
|
||||
default: "bg-foreground text-background",
|
||||
inverse: "bg-background text-foreground border-y border-border",
|
||||
muted: "bg-muted text-foreground border-y border-border",
|
||||
};
|
||||
return (
|
||||
<div className={cn("w-full py-2 text-center text-xs tracking-[0.18em] uppercase", toneClass[tone])}>
|
||||
<div className="container mx-auto flex flex-col items-center justify-center gap-2 px-6 sm:flex-row">
|
||||
<span>{text}</span>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
to={ctaHref || "#"}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
editor/components/landing/image-gallery.editor.tsx
Normal file
48
editor/components/landing/image-gallery.editor.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Images } from "lucide-react";
|
||||
import { ImageGallery, type ImageGalleryProps } from "@/editor/components/landing/image-gallery";
|
||||
|
||||
export const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
|
||||
label: "Image gallery",
|
||||
icon: <Images size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Lookbook",
|
||||
heading: "Spring in the studio",
|
||||
subheading: "",
|
||||
layout: "editorial",
|
||||
items: [
|
||||
{ src: "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1600&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
{ src: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Grid", value: "grid" },
|
||||
{ label: "Masonry", value: "masonry" },
|
||||
{ label: "Editorial (mosaic)", value: "editorial" },
|
||||
],
|
||||
},
|
||||
items: {
|
||||
label: "Images",
|
||||
type: "array",
|
||||
defaultItemProps: { src: "", alt: "" },
|
||||
getItemSummary: (it) => it?.alt || it?.src || "Image",
|
||||
arrayFields: {
|
||||
src: { label: "Image URL", type: "text" },
|
||||
alt: { label: "Alt text", type: "text", contentEditable: true },
|
||||
caption: { label: "Caption", type: "text", contentEditable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <ImageGallery {...props} />,
|
||||
};
|
||||
92
editor/components/landing/image-gallery.tsx
Normal file
92
editor/components/landing/image-gallery.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type ImageGalleryProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
layout: "grid" | "masonry" | "editorial";
|
||||
items: Array<{ src: string; alt: string; caption?: string }>;
|
||||
};
|
||||
|
||||
export function ImageGallery({ tagline, heading, subheading, layout, items }: ImageGalleryProps) {
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
{(tagline || heading || subheading) && (
|
||||
<div className="mx-auto mb-12 max-w-2xl text-center">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
{heading ? <Typography variant="h2">{heading}</Typography> : null}
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout === "masonry" ? (
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
|
||||
{items.map((it, i) => (
|
||||
<figure key={i} className="mb-4 break-inside-avoid">
|
||||
<img
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="w-full rounded-md object-cover"
|
||||
/>
|
||||
{it.caption ? (
|
||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
||||
{it.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : layout === "editorial" ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
||||
{items.slice(0, 5).map((it, i) => (
|
||||
<figure
|
||||
key={i}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md bg-muted",
|
||||
i === 0 && "md:col-span-7 md:row-span-2",
|
||||
i === 1 && "md:col-span-5",
|
||||
i === 2 && "md:col-span-5",
|
||||
i === 3 && "md:col-span-6",
|
||||
i === 4 && "md:col-span-6",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((it, i) => (
|
||||
<figure key={i}>
|
||||
<img
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="aspect-[4/5] w-full rounded-md object-cover"
|
||||
/>
|
||||
{it.caption ? (
|
||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
||||
{it.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
37
editor/components/landing/newsletter-cta.editor.tsx
Normal file
37
editor/components/landing/newsletter-cta.editor.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Mail } from "lucide-react";
|
||||
import { NewsletterCta, type NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta";
|
||||
|
||||
export const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
|
||||
label: "Newsletter",
|
||||
icon: <Mail size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Stay in the loop",
|
||||
heading: "Letters from the studio",
|
||||
subheading:
|
||||
"New collections, mill stories, and the occasional invitation to in-person events. Twice a month.",
|
||||
buttonLabel: "Subscribe",
|
||||
endpoint: "",
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
|
||||
layout: "split",
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
buttonLabel: { label: "Button label", type: "text", contentEditable: true },
|
||||
endpoint: { label: "Submit endpoint", type: "text" },
|
||||
imageUrl: { label: "Image URL", type: "text" },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Split (image + form)", value: "split" },
|
||||
{ label: "Stacked (centered)", value: "stacked" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <NewsletterCta {...props} />,
|
||||
};
|
||||
139
editor/components/landing/newsletter-cta.tsx
Normal file
139
editor/components/landing/newsletter-cta.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type NewsletterCtaProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
buttonLabel: string;
|
||||
endpoint: string;
|
||||
imageUrl: string;
|
||||
layout: "split" | "stacked";
|
||||
};
|
||||
|
||||
export function NewsletterCta({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
buttonLabel,
|
||||
endpoint,
|
||||
imageUrl,
|
||||
layout,
|
||||
}: NewsletterCtaProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (endpoint) {
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Form = (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="flex w-full max-w-md items-center border-b border-foreground/30 focus-within:border-foreground"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="flex-1 bg-transparent py-3 text-sm placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "…" : buttonLabel} →
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
if (layout === "split") {
|
||||
return (
|
||||
<section className="bg-background">
|
||||
<div className="container mx-auto max-w-7xl px-6 py-16 md:py-24">
|
||||
<div className="grid grid-cols-1 items-center gap-12 md:grid-cols-2">
|
||||
<div>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="aspect-[5/4] w-full rounded-md object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3 max-w-md">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className="mt-8 w-full">
|
||||
{submitted ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thanks — we'll be in touch.
|
||||
</p>
|
||||
) : (
|
||||
Form
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/40 py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-2xl px-6 text-center">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<Typography variant="h2">{heading}</Typography>
|
||||
{subheading ? (
|
||||
<Typography variant="subtitle1" className="mt-3">
|
||||
{subheading}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className={cn("mx-auto mt-10 flex w-full max-w-md justify-center")}>
|
||||
{submitted ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thanks — we'll be in touch.
|
||||
</p>
|
||||
) : (
|
||||
Form
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
editor/components/logos/logos.editor.tsx
Normal file
42
editor/components/logos/logos.editor.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Award } from "lucide-react";
|
||||
import { Logos, type LogosProps } from "@/editor/components/logos/logos";
|
||||
|
||||
export const logosEditor: ComponentConfig<LogosProps> = {
|
||||
label: "Press / Logos",
|
||||
icon: <Award size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "As seen in",
|
||||
layout: "row",
|
||||
items: [
|
||||
{ src: "https://logo.clearbit.com/vogue.com", alt: "Vogue" },
|
||||
{ src: "https://logo.clearbit.com/highsnobiety.com", alt: "Highsnobiety" },
|
||||
{ src: "https://logo.clearbit.com/gq.com", alt: "GQ" },
|
||||
{ src: "https://logo.clearbit.com/dezeen.com", alt: "Dezeen" },
|
||||
{ src: "https://logo.clearbit.com/wallpaper.com", alt: "Wallpaper*" },
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Row", value: "row" },
|
||||
{ label: "Marquee", value: "marquee" },
|
||||
],
|
||||
},
|
||||
items: {
|
||||
label: "Logos",
|
||||
type: "array",
|
||||
defaultItemProps: { src: "", alt: "" },
|
||||
getItemSummary: (it) => it?.alt || "Logo",
|
||||
arrayFields: {
|
||||
src: { label: "Image URL", type: "text" },
|
||||
alt: { label: "Alt text", type: "text", contentEditable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <Logos {...props} />,
|
||||
};
|
||||
44
editor/components/logos/logos.tsx
Normal file
44
editor/components/logos/logos.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
export type LogosProps = {
|
||||
tagline: string;
|
||||
items: Array<{ src: string; alt: string }>;
|
||||
layout: "row" | "marquee";
|
||||
};
|
||||
|
||||
export function Logos({ tagline, items, layout }: LogosProps) {
|
||||
return (
|
||||
<section className="border-y border-border bg-muted/40 py-12">
|
||||
<div className="container mx-auto max-w-7xl px-6">
|
||||
{tagline ? (
|
||||
<p className="mb-8 text-center text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
{layout === "marquee" ? (
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex animate-[marquee_30s_linear_infinite] gap-16 [--gap:4rem]">
|
||||
{[...items, ...items].map((it, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="h-7 w-auto opacity-60 grayscale"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
||||
{items.map((it, i) => (
|
||||
<img
|
||||
key={i}
|
||||
src={it.src}
|
||||
alt={it.alt}
|
||||
className="h-7 w-auto opacity-60 grayscale"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
86
editor/components/navigation/navigation.editor.tsx
Normal file
86
editor/components/navigation/navigation.editor.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Menu as MenuIcon } from "lucide-react";
|
||||
import { Navigation, type NavigationProps } from "@/editor/components/navigation/navigation";
|
||||
|
||||
export const navigationEditor: ComponentConfig<NavigationProps> = {
|
||||
label: "Navigation",
|
||||
icon: <MenuIcon size={16} />,
|
||||
category: "navigation",
|
||||
global: true,
|
||||
defaultProps: {
|
||||
brand: "Maison",
|
||||
links: [
|
||||
{ label: "Shop", href: "/collections" },
|
||||
{ label: "Lookbook", href: "/lookbook" },
|
||||
{ label: "Journal", href: "/journal" },
|
||||
{ label: "About", href: "/about" },
|
||||
],
|
||||
showSearch: "yes",
|
||||
showCart: "yes",
|
||||
sticky: "yes",
|
||||
tone: "default",
|
||||
bannerText: "",
|
||||
bannerTone: "accent",
|
||||
},
|
||||
fields: {
|
||||
brand: { label: "Brand", type: "text", contentEditable: true },
|
||||
links: {
|
||||
label: "Links",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "Link", href: "/" },
|
||||
getItemSummary: (it) => it?.label || "Link",
|
||||
arrayFields: {
|
||||
label: { label: "Label", type: "text", contentEditable: true },
|
||||
href: { label: "Link", type: "text" },
|
||||
},
|
||||
},
|
||||
bannerText: {
|
||||
label: "Banner text",
|
||||
type: "text",
|
||||
contentEditable: true,
|
||||
},
|
||||
bannerTone: {
|
||||
label: "Banner tone",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Accent", value: "accent" },
|
||||
{ label: "Inverse (dark)", value: "inverse" },
|
||||
],
|
||||
},
|
||||
showSearch: {
|
||||
label: "Search icon",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showCart: {
|
||||
label: "Cart icon",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
sticky: {
|
||||
label: "Position",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Sticky", value: "yes" },
|
||||
{ label: "Static", value: "no" },
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Muted", value: "muted" },
|
||||
{ label: "Inverse (dark)", value: "inverse" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <Navigation {...props} />,
|
||||
};
|
||||
232
editor/components/navigation/navigation.tsx
Normal file
232
editor/components/navigation/navigation.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/editor/components/ui/sheet";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
export type NavigationProps = {
|
||||
brand: string;
|
||||
links: Array<{ label: string; href: string }>;
|
||||
showSearch: "yes" | "no";
|
||||
showCart: "yes" | "no";
|
||||
sticky: "yes" | "no";
|
||||
tone: "default" | "muted" | "inverse";
|
||||
bannerText: string;
|
||||
bannerTone: "default" | "accent" | "inverse";
|
||||
};
|
||||
|
||||
export function Navigation({
|
||||
brand,
|
||||
links,
|
||||
showSearch,
|
||||
showCart,
|
||||
sticky,
|
||||
tone,
|
||||
bannerText,
|
||||
bannerTone,
|
||||
}: NavigationProps) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const cart = useShopifyCart();
|
||||
const itemCount = cart?.itemCount ?? 0;
|
||||
|
||||
const toneClass: Record<NavigationProps["tone"], string> = {
|
||||
default: "bg-background text-foreground border-b border-border",
|
||||
muted: "bg-muted/40 text-foreground border-b border-border",
|
||||
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" && "backdrop-blur",
|
||||
toneClass[tone],
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-6 md:h-20">
|
||||
<Link
|
||||
to="/"
|
||||
className="font-semibold tracking-tight"
|
||||
style={{ fontSize: "1.125rem", letterSpacing: "0.02em" }}
|
||||
>
|
||||
{brand}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-8 md:flex">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href + l.label}
|
||||
to={l.href}
|
||||
className="text-sm tracking-wide opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{showSearch === "yes" && (
|
||||
<button
|
||||
aria-label="Search"
|
||||
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
|
||||
>
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
{showCart === "yes" && (
|
||||
<button
|
||||
onClick={() => setCartOpen(true)}
|
||||
aria-label="Cart"
|
||||
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5"
|
||||
>
|
||||
<ShoppingBag size={18} strokeWidth={1.5} />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-medium text-background">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Menu"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:hidden"
|
||||
>
|
||||
<MenuIcon size={20} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="w-[88vw] max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">{brand}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-1">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href + l.label}
|
||||
to={l.href}
|
||||
className="rounded-md px-3 py-3 text-base hover:bg-muted"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Cart drawer */}
|
||||
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
|
||||
<SheetContent side="right" className="flex w-[92vw] max-w-md flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Cart</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="-mx-6 mt-4 flex-1 overflow-y-auto px-6">
|
||||
{cart?.items?.length ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{cart.items.map((line: any) => (
|
||||
<li key={line.id} className="flex gap-4 py-4">
|
||||
<div className="aspect-square h-20 flex-shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
{line.merchandise?.product?.images?.edges?.[0]?.node?.url ? (
|
||||
<img
|
||||
src={line.merchandise.product.images.edges[0].node.url}
|
||||
alt={line.merchandise.product.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<p className="text-sm font-medium">{line.merchandise?.product?.title}</p>
|
||||
{line.merchandise?.title && line.merchandise.title !== "Default Title" ? (
|
||||
<p className="text-xs text-muted-foreground">{line.merchandise.title}</p>
|
||||
) : null}
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => cart.updateItemQuantity(line.id, line.quantity - 1)}
|
||||
className="h-6 w-6 rounded-full border border-border hover:bg-muted"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span>{line.quantity}</span>
|
||||
<button
|
||||
onClick={() => cart.updateItemQuantity(line.id, line.quantity + 1)}
|
||||
className="h-6 w-6 rounded-full border border-border hover:bg-muted"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm">
|
||||
{line.merchandise?.price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: line.merchandise.price.currencyCode,
|
||||
}).format(parseFloat(line.merchandise.price.amount) * line.quantity)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
|
||||
<ShoppingBag size={28} strokeWidth={1.25} />
|
||||
<p>Your cart is empty.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cart?.items?.length ? (
|
||||
<div className="border-t border-border pt-4">
|
||||
<div className="mb-4 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="font-medium">
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: cart.cart?.cost?.totalAmount?.currencyCode ?? "USD",
|
||||
}).format(cart.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={cart.checkoutUrl ?? "#"}
|
||||
className="inline-flex w-full items-center justify-center rounded-full bg-foreground px-4 py-3 text-sm font-medium text-background transition-opacity hover:opacity-90"
|
||||
>
|
||||
Checkout
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
editor/components/testimonials/testimonials.editor.tsx
Normal file
56
editor/components/testimonials/testimonials.editor.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Quote } from "lucide-react";
|
||||
import { Testimonials, type TestimonialsProps } from "@/editor/components/testimonials/testimonials";
|
||||
|
||||
export const testimonialsEditor: ComponentConfig<TestimonialsProps> = {
|
||||
label: "Testimonials",
|
||||
icon: <Quote size={16} />,
|
||||
category: "content",
|
||||
defaultProps: {
|
||||
tagline: "Reviews",
|
||||
heading: "What our customers say",
|
||||
items: [
|
||||
{
|
||||
quote:
|
||||
"I've been wearing the same linen shirt for two summers now and it's somehow gotten better with every wash.",
|
||||
author: "Mara K.",
|
||||
role: "Berlin",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Considered cuts, neutral palette, real fabric. Exactly what I want when I'm getting dressed in the dark.",
|
||||
author: "Theo R.",
|
||||
role: "Brooklyn",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"The shipping was thoughtful, the packaging was minimal, and the trousers fit on the first try. Rare combination.",
|
||||
author: "Ines P.",
|
||||
role: "Paris",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1580489944761-15a19d654956?auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
items: {
|
||||
label: "Items",
|
||||
type: "array",
|
||||
defaultItemProps: { quote: "", author: "", role: "" },
|
||||
getItemSummary: (it) => it?.author || "Testimonial",
|
||||
arrayFields: {
|
||||
quote: { label: "Quote", type: "textarea", contentEditable: true },
|
||||
author: { label: "Author", type: "text", contentEditable: true },
|
||||
role: { label: "Role", type: "text", contentEditable: true },
|
||||
avatar: { label: "Avatar URL", type: "text" },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <Testimonials {...props} />,
|
||||
};
|
||||
81
editor/components/testimonials/testimonials.tsx
Normal file
81
editor/components/testimonials/testimonials.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { Typography } from "@/editor/theme/Typography";
|
||||
|
||||
export type TestimonialsProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
items: Array<{
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
avatar?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function Testimonials({ tagline, heading, items }: TestimonialsProps) {
|
||||
const [i, setI] = useState(0);
|
||||
const total = items.length;
|
||||
const item = items[i];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/40 py-20 md:py-28">
|
||||
<div className="container mx-auto max-w-4xl px-6 text-center">
|
||||
{tagline ? (
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{tagline}
|
||||
</p>
|
||||
) : null}
|
||||
{heading ? <Typography variant="h2">{heading}</Typography> : null}
|
||||
|
||||
{item ? (
|
||||
<figure className="mx-auto mt-12 flex max-w-2xl flex-col items-center">
|
||||
<blockquote
|
||||
className="text-balance text-foreground"
|
||||
style={{ fontSize: "clamp(1.25rem, 2.4vw, 1.75rem)", lineHeight: 1.4 }}
|
||||
>
|
||||
{item.quote}
|
||||
</blockquote>
|
||||
<figcaption className="mt-8 flex items-center gap-3">
|
||||
{item.avatar ? (
|
||||
<img
|
||||
src={item.avatar}
|
||||
alt={item.author}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium">{item.author}</p>
|
||||
{item.role ? (
|
||||
<p className="text-xs text-muted-foreground">{item.role}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
|
||||
{total > 1 ? (
|
||||
<div className="mt-10 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setI((p) => (p - 1 + total) % total)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border hover:bg-background"
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{i + 1} / {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setI((p) => (p + 1) % total)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border hover:bg-background"
|
||||
aria-label="Next"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
66
editor/components/ui/accordion.tsx
Normal file
66
editor/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
60
editor/components/ui/alert.tsx
Normal file
60
editor/components/ui/alert.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/editor/lib/utils"
|
||||
|
||||
type AlertVariant = "default" | "destructive"
|
||||
|
||||
const baseClasses =
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current"
|
||||
|
||||
const variantClasses: Record<AlertVariant, string> = {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
}
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { variant?: AlertVariant }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(baseClasses, variantClasses[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
53
editor/components/ui/avatar.tsx
Normal file
53
editor/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
48
editor/components/ui/badge.tsx
Normal file
48
editor/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
148
editor/components/ui/breadcrumb.tsx
Normal file
148
editor/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement, {
|
||||
className: cn(
|
||||
'hover:text-foreground transition-colors',
|
||||
children.props.className,
|
||||
className
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="19" cy="12" r="1"></circle>
|
||||
<circle cx="5" cy="12" r="1"></circle>
|
||||
</svg>
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
97
editor/components/ui/button-group.tsx
Normal file
97
editor/components/ui/button-group.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
|
||||
// Utility function to combine classNames
|
||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
// Button group variants helper
|
||||
function getButtonGroupVariants(
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
): string {
|
||||
const baseStyles =
|
||||
'flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*="w-"])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2';
|
||||
|
||||
const orientationStyles = {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
};
|
||||
|
||||
return cn(baseStyles, orientationStyles[orientation]);
|
||||
}
|
||||
|
||||
interface ButtonGroupProps extends React.ComponentProps<'div'> {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
...props
|
||||
}: ButtonGroupProps) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(getButtonGroupVariants(orientation), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonGroupTextProps extends React.ComponentProps<'div'> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonGroupTextProps) {
|
||||
const Comp = asChild ? 'div' : 'div';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button-group-text"
|
||||
className={cn(
|
||||
'bg-muted flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonGroupSeparatorProps extends React.ComponentProps<'div'> {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: ButtonGroupSeparatorProps) {
|
||||
const separatorClasses =
|
||||
orientation === 'vertical' ? 'w-px h-auto' : 'h-px w-auto';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="button-group-separator"
|
||||
className={cn(
|
||||
'bg-border relative !m-0 self-stretch',
|
||||
separatorClasses,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
|
||||
export type {
|
||||
ButtonGroupProps,
|
||||
ButtonGroupTextProps,
|
||||
ButtonGroupSeparatorProps,
|
||||
};
|
||||
58
editor/components/ui/button.tsx
Normal file
58
editor/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border bg-background text-foreground shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
editor/components/ui/card.tsx
Normal file
92
editor/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
245
editor/components/ui/carousel.tsx
Normal file
245
editor/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
import { Button } from './button';
|
||||
|
||||
interface CarouselContextType {
|
||||
currentIndex: number;
|
||||
totalItems: number;
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
const CarouselContext = createContext<CarouselContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
function useCarousel() {
|
||||
const context = useContext(CarouselContext);
|
||||
if (!context) {
|
||||
throw new Error('Carousel components must be used within a Carousel');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface CarouselProps {
|
||||
children: React.ReactNode;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
className?: string;
|
||||
autoPlay?: boolean;
|
||||
autoPlayInterval?: number;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
children,
|
||||
orientation = 'horizontal',
|
||||
className,
|
||||
autoPlay = false,
|
||||
autoPlayInterval = 3000,
|
||||
}: CarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const itemCount = React.Children.count(children);
|
||||
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const canScrollPrev = currentIndex > 0;
|
||||
const canScrollNext = currentIndex < itemCount - 1;
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
setCurrentIndex((prev) => Math.max(0, prev - 1));
|
||||
}, []);
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => Math.min(itemCount - 1, prev + 1));
|
||||
}, [itemCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlay) return;
|
||||
|
||||
autoPlayTimerRef.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
if (prev >= itemCount - 1) {
|
||||
return 0;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, autoPlayInterval);
|
||||
|
||||
return () => {
|
||||
if (autoPlayTimerRef.current) {
|
||||
clearInterval(autoPlayTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoPlay, autoPlayInterval, itemCount]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
currentIndex,
|
||||
totalItems: itemCount,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
orientation,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface CarouselContentProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CarouselContent({ className, children }: CarouselContentProps) {
|
||||
const { currentIndex, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('overflow-hidden', className)}
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex transition-transform duration-300 ease-out',
|
||||
orientation === 'horizontal' ? 'flex-row' : 'flex-col'
|
||||
)}
|
||||
style={{
|
||||
transform:
|
||||
orientation === 'horizontal'
|
||||
? `translateX(-${currentIndex * 100}%)`
|
||||
: `translateY(-${currentIndex * 100}%)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CarouselItemProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CarouselItem({ className, children }: CarouselItemProps) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn('min-w-0 shrink-0 grow-0 basis-full', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CarouselPreviousProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CarouselPrevious({ className }: CarouselPreviousProps) {
|
||||
const { scrollPrev, canScrollPrev, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant="outline"
|
||||
onClick={scrollPrev}
|
||||
disabled={!canScrollPrev}
|
||||
className={cn(
|
||||
'absolute size-10 rounded-full p-0 flex items-center justify-center',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 left-2 -translate-y-1/2'
|
||||
: 'top-2 left-1/2 -translate-x-1/2 -rotate-90',
|
||||
className
|
||||
)}
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface CarouselNextProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CarouselNext({ className }: CarouselNextProps) {
|
||||
const { scrollNext, canScrollNext, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant="outline"
|
||||
onClick={scrollNext}
|
||||
disabled={!canScrollNext}
|
||||
className={cn(
|
||||
'absolute size-10 rounded-full p-0 flex items-center justify-center',
|
||||
orientation === 'horizontal'
|
||||
? 'top-1/2 right-2 -translate-y-1/2'
|
||||
: 'bottom-2 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className
|
||||
)}
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
useCarousel,
|
||||
};
|
||||
266
editor/components/ui/dialog.tsx
Normal file
266
editor/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useCallback, useContext, createContext } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface DialogContextType {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined);
|
||||
|
||||
function useDialog() {
|
||||
const context = useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error('Dialog components must be used within a Dialog');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const setOpen = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
||||
const { setOpen } = useDialog();
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement, {
|
||||
...props,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
setOpen(true);
|
||||
children.props.onClick?.(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
setOpen(true);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogPortal({ children }: { children: React.ReactNode }) {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
||||
const { setOpen } = useDialog();
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement, {
|
||||
...props,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
setOpen(false);
|
||||
children.props.onClick?.(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
setOpen(false);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogOverlayProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
function DialogOverlay({ className, onClick, ...props }: DialogOverlayProps) {
|
||||
const { setOpen } = useDialog();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="dialog-overlay"
|
||||
className={clsx('fixed inset-0 z-50 bg-black/50', className)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={(e) => {
|
||||
setOpen(false);
|
||||
onClick?.(e as any);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const { open } = useDialog();
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<DialogOverlay />
|
||||
<motion.div
|
||||
data-slot="dialog-content"
|
||||
className={clsx(
|
||||
'bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-screen-md max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border p-6 shadow-lg',
|
||||
className
|
||||
)}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 0 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M18 6l-12 12M6 6l12 12" />
|
||||
</svg>
|
||||
</DialogClose>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={clsx(
|
||||
'flex flex-col gap-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={clsx(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
data-slot="dialog-title"
|
||||
className={clsx('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="dialog-description"
|
||||
className={clsx('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
AnimatePresence,
|
||||
};
|
||||
105
editor/components/ui/empty.tsx
Normal file
105
editor/components/ui/empty.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/editor/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
86
editor/components/ui/input-otp.tsx
Normal file
86
editor/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-disabled:opacity-50',
|
||||
containerClassName
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r border-border text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l first:border-border last:rounded-r-md last:border-border data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
21
editor/components/ui/input.tsx
Normal file
21
editor/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/editor/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"text-foreground file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
213
editor/components/ui/item.tsx
Normal file
213
editor/components/ui/item.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React from 'react';
|
||||
|
||||
// Utility function to combine classNames
|
||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
// Item variants helper
|
||||
function getItemVariants(
|
||||
variant: 'default' | 'outline' | 'muted',
|
||||
size: 'default' | 'sm'
|
||||
): string {
|
||||
const baseStyles =
|
||||
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]';
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
default: 'p-4 gap-4',
|
||||
sm: 'py-3 px-4 gap-2.5',
|
||||
};
|
||||
|
||||
return cn(baseStyles, variantStyles[variant], sizeStyles[size]);
|
||||
}
|
||||
|
||||
// Item media variants helper
|
||||
function getItemMediaVariants(variant: 'default' | 'icon' | 'image'): string {
|
||||
const baseStyles =
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5';
|
||||
|
||||
const variantStyles = {
|
||||
default: 'bg-transparent',
|
||||
icon: "size-8 border border-border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
|
||||
};
|
||||
|
||||
return cn(baseStyles, variantStyles[variant]);
|
||||
}
|
||||
|
||||
interface ItemGroupProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemGroup({ className, ...props }: ItemGroupProps) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn('group/item-group flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemSeparatorProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemSeparator({ className, ...props }: ItemSeparatorProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-separator"
|
||||
className={cn('my-0 border-t border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemProps extends React.ComponentProps<'div'> {
|
||||
variant?: 'default' | 'outline' | 'muted';
|
||||
size?: 'default' | 'sm';
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: ItemProps) {
|
||||
const Comp = asChild ? 'div' : 'div';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(getItemVariants(variant, size), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemMediaProps extends React.ComponentProps<'div'> {
|
||||
variant?: 'default' | 'icon' | 'image';
|
||||
}
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: ItemMediaProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(getItemMediaVariants(variant), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemContentProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemContent({ className, ...props }: ItemContentProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemTitleProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemTitle({ className, ...props }: ItemTitleProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemDescriptionProps extends React.ComponentProps<'p'> {}
|
||||
|
||||
function ItemDescription({ className, ...props }: ItemDescriptionProps) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemActionsProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemActions({ className, ...props }: ItemActionsProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemHeaderProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemHeader({ className, ...props }: ItemHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemFooterProps extends React.ComponentProps<'div'> {}
|
||||
|
||||
function ItemFooter({ className, ...props }: ItemFooterProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
};
|
||||
151
editor/components/ui/navigation-menu.tsx
Normal file
151
editor/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[active=true]:bg-accent/50 outline-none transition-[color,box-shadow]"
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
127
editor/components/ui/pagination.tsx
Normal file
127
editor/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/editor/lib/utils"
|
||||
import { buttonVariants, type Button } from "@/editor/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
44
editor/components/ui/progress.tsx
Normal file
44
editor/components/ui/progress.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
// Utility function to combine classNames
|
||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
interface ProgressProps extends React.ComponentProps<'div'> {
|
||||
value?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
className
|
||||
)}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
export type { ProgressProps };
|
||||
287
editor/components/ui/select.tsx
Normal file
287
editor/components/ui/select.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
interface SelectContextType {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
}
|
||||
|
||||
const SelectContext = createContext<SelectContextType | undefined>(undefined);
|
||||
|
||||
function useSelect() {
|
||||
const context = useContext(SelectContext);
|
||||
if (!context) {
|
||||
throw new Error('Select components must be used within a Select');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Select({
|
||||
value: controlledValue,
|
||||
onValueChange,
|
||||
children,
|
||||
}: SelectProps) {
|
||||
const [internalValue, setInternalValue] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onValueChange?.(newValue);
|
||||
setOpen(false);
|
||||
},
|
||||
[isControlled, onValueChange]
|
||||
);
|
||||
|
||||
// Handle clicking outside to close the menu
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SelectContext.Provider
|
||||
value={{ open, setOpen, value, setValue: handleValueChange }}
|
||||
>
|
||||
<div ref={containerRef} data-slot="select" className="relative">
|
||||
{children}
|
||||
</div>
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: React.ReactNode;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
placeholder = 'Select...',
|
||||
...props
|
||||
}: SelectTriggerProps) {
|
||||
const { open, setOpen, value } = useSelect();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
data-slot="select-trigger"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*="text-"])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 h-9 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children || <span className="text-muted-foreground">{placeholder}</span>}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(
|
||||
'size-4 opacity-50 transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectValueProps {
|
||||
children?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
children,
|
||||
placeholder = 'Select...',
|
||||
}: SelectValueProps) {
|
||||
const { value } = useSelect();
|
||||
|
||||
return (
|
||||
<span data-slot="select-value">{children || value || placeholder}</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SelectContent({ className, children, ...props }: SelectContentProps) {
|
||||
const { open } = useSelect();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground absolute z-50 min-w-[8rem] rounded-md border border-border shadow-md overflow-hidden top-full mt-2 left-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="p-1 overflow-y-auto max-h-60">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
value,
|
||||
children,
|
||||
disabled = false,
|
||||
className,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const { value: selectedValue, setValue } = useSelect();
|
||||
const isSelected = selectedValue === value;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="select-item"
|
||||
onClick={() => !disabled && setValue(value)}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*="text-"])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none transition-colors',
|
||||
!disabled &&
|
||||
'hover:bg-accent hover:text-accent-foreground cursor-pointer',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SelectGroup({ className, children, ...props }: SelectGroupProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="select-group"
|
||||
className={cn('overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectLabelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SelectLabel({ className, children, ...props }: SelectLabelProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="select-label"
|
||||
className={cn(
|
||||
'text-muted-foreground px-2 py-1.5 text-xs font-semibold',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="select-separator"
|
||||
className={cn(
|
||||
'bg-border pointer-events-none -mx-1 my-1 h-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
};
|
||||
28
editor/components/ui/separator.tsx
Normal file
28
editor/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
316
editor/components/ui/sheet.tsx
Normal file
316
editor/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useContext, createContext } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
interface SheetContextType {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
side: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
const SheetContext = createContext<SheetContextType | undefined>(undefined);
|
||||
|
||||
function useSheet() {
|
||||
const context = useContext(SheetContext);
|
||||
if (!context) {
|
||||
throw new Error('Sheet components must be used within a Sheet');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface SheetProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
function Sheet({
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
side = 'right',
|
||||
}: SheetProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const setOpen = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalOpen(newOpen);
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[isControlled, onOpenChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetContext.Provider value={{ open, setOpen, side }}>
|
||||
{children}
|
||||
</SheetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTrigger(
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }
|
||||
) {
|
||||
const { setOpen } = useSheet();
|
||||
const { children, asChild, ...rest } = props;
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement, {
|
||||
...rest,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
setOpen(true);
|
||||
children.props.onClick?.(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
data-slot="sheet-trigger"
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
setOpen(true);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetPortal({ children }: { children: React.ReactNode }) {
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { setOpen } = useSheet();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="sheet-overlay"
|
||||
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={(e) => {
|
||||
setOpen(false);
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
const { open, setOpen, side } = useSheet();
|
||||
|
||||
const sideClasses = {
|
||||
right: 'inset-y-0 right-0 h-full w-3/4 sm:max-w-sm border-l',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 sm:max-w-sm border-r',
|
||||
top: 'inset-x-0 top-0 h-auto border-b',
|
||||
bottom: 'inset-x-0 bottom-0 h-auto border-t',
|
||||
};
|
||||
|
||||
const slideVariants = {
|
||||
right: {
|
||||
initial: { x: 400, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: 400, opacity: 0 },
|
||||
},
|
||||
left: {
|
||||
initial: { x: -400, opacity: 0 },
|
||||
animate: { x: 0, opacity: 1 },
|
||||
exit: { x: -400, opacity: 0 },
|
||||
},
|
||||
top: {
|
||||
initial: { y: -400, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -400, opacity: 0 },
|
||||
},
|
||||
bottom: {
|
||||
initial: { y: 400, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: 400, opacity: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<>
|
||||
<SheetOverlay />
|
||||
<motion.div
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background fixed z-50 flex flex-col gap-0 shadow-lg',
|
||||
sideClasses[side],
|
||||
className,
|
||||
)}
|
||||
variants={slideVariants[side]}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
data-slot="sheet-close"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M18 6l-12 12M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetClose(
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }
|
||||
) {
|
||||
const { setOpen } = useSheet();
|
||||
const { children, asChild, ...rest } = props;
|
||||
|
||||
if (asChild && React.isValidElement(children)) {
|
||||
return React.cloneElement(children as React.ReactElement, {
|
||||
...rest,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
setOpen(false);
|
||||
children.props.onClick?.(e);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
data-slot="sheet-close"
|
||||
{...rest}
|
||||
onClick={(e) => {
|
||||
setOpen(false);
|
||||
props.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn(
|
||||
'flex flex-col gap-1.5 p-6 border-b border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 p-6 border-t border-border sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<'h2'>) {
|
||||
return (
|
||||
<h2
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SheetBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SheetBody({ className, children, ...props }: SheetBodyProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-body"
|
||||
className={cn('flex-1 overflow-y-auto px-6 py-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetBody,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
AnimatePresence,
|
||||
};
|
||||
13
editor/components/ui/skeleton.tsx
Normal file
13
editor/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/editor/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
23
editor/components/ui/sonner.tsx
Normal file
23
editor/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import Button from '@/editor/components/ui/button';
|
||||
|
||||
export function SonnerDemo() {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
toast('Event has been created', {
|
||||
description: 'Sunday, December 03, 2023 at 9:00 AM',
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => console.log('Undo'),
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Show Toast
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
38
editor/components/ui/spinner.tsx
Normal file
38
editor/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
interface SpinnerProps extends React.ComponentProps<'svg'> {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'size-3',
|
||||
md: 'size-4',
|
||||
lg: 'size-6',
|
||||
xl: 'size-8',
|
||||
};
|
||||
|
||||
function Spinner({ className, size = 'md', ...props }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('animate-spin', sizeClasses[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
export type { SpinnerProps };
|
||||
74
editor/components/ui/switch.tsx
Normal file
74
editor/components/ui/switch.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
interface SwitchProps extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'type'
|
||||
> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
|
||||
const [isChecked, setIsChecked] = React.useState(checked ?? false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newChecked = e.target.checked;
|
||||
setIsChecked(newChecked);
|
||||
onCheckedChange?.(newChecked);
|
||||
props.onChange?.(e);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (checked !== undefined) {
|
||||
setIsChecked(checked);
|
||||
}
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex">
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px]',
|
||||
isChecked
|
||||
? 'bg-primary focus-visible:ring-ring/50 focus-visible:border-ring'
|
||||
: 'bg-input dark:bg-input/80 focus-visible:ring-ring/50 focus-visible:border-ring',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setIsChecked(!isChecked);
|
||||
onCheckedChange?.(!isChecked);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:bg-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform',
|
||||
isChecked
|
||||
? 'translate-x-[calc(100%-2px)] dark:bg-primary-foreground'
|
||||
: 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
113
editor/components/ui/table.tsx
Normal file
113
editor/components/ui/table.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/editor/lib/utils';
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto rounded-md border border-border"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn('[&_tr]:border-b border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
'bg-muted/50 border-t border-border font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground bg-muted/30 h-10 px-4 py-2 text-left align-middle font-semibold whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
66
editor/components/ui/tabs.tsx
Normal file
66
editor/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
18
editor/components/ui/textarea.tsx
Normal file
18
editor/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/editor/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
51
editor/config/icons.tsx
Normal file
51
editor/config/icons.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
|
||||
Play,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Star,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const iconMap: Record<string, LucideIcon> = {
|
||||
"arrow-right": ArrowRight,
|
||||
"arrow-up-right": ArrowUpRight,
|
||||
check: Check,
|
||||
"chevron-right": ChevronRight,
|
||||
download: Download,
|
||||
"external-link": ExternalLink,
|
||||
|
||||
play: Play,
|
||||
rocket: Rocket,
|
||||
sparkles: Sparkles,
|
||||
star: Star,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
export const iconOptions = [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Arrow right", value: "arrow-right" },
|
||||
{ label: "Arrow up right", value: "arrow-up-right" },
|
||||
{ label: "Check", value: "check" },
|
||||
{ label: "Chevron right", value: "chevron-right" },
|
||||
{ label: "Download", value: "download" },
|
||||
{ label: "External link", value: "external-link" },
|
||||
|
||||
{ label: "Play", value: "play" },
|
||||
{ label: "Rocket", value: "rocket" },
|
||||
{ label: "Sparkles", value: "sparkles" },
|
||||
{ label: "Star", value: "star" },
|
||||
{ label: "Zap", value: "zap" },
|
||||
];
|
||||
|
||||
export const resolveIcon = (name: string): LucideIcon | null => {
|
||||
if (!name || name === "none") return null;
|
||||
return iconMap[name] ?? null;
|
||||
};
|
||||
88
editor/config/index.tsx
Normal file
88
editor/config/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
createFieldShopifyProduct,
|
||||
createFieldShopifyCollection,
|
||||
} from "@reacteditor/field-shopify";
|
||||
|
||||
import { navigationEditor } from "@/editor/components/navigation/navigation.editor";
|
||||
import { footerEditor } from "@/editor/components/footer/footer.editor";
|
||||
|
||||
import { heroEditor } from "@/editor/components/hero/hero.editor";
|
||||
import { bannerEditor } from "@/editor/components/landing/banner.editor";
|
||||
|
||||
import { createFeaturedProductEditor } from "@/editor/components/commerce/featured-product.editor";
|
||||
import { createProductsGridEditor } from "@/editor/components/commerce/products-grid.editor";
|
||||
import { createProductsCarouselEditor } from "@/editor/components/commerce/products-carousel.editor";
|
||||
import { collectionGridEditor } from "@/editor/components/commerce/collection-grid.editor";
|
||||
import { createCollectionEditor } from "@/editor/components/commerce/collection.editor";
|
||||
import { createProductDetailsEditor } from "@/editor/components/commerce/product-details.editor";
|
||||
import { createRecommendedProductsEditor } from "@/editor/components/commerce/recommended-products.editor";
|
||||
|
||||
import { featuresEditor } from "@/editor/components/features/features.editor";
|
||||
import { testimonialsEditor } from "@/editor/components/testimonials/testimonials.editor";
|
||||
import { imageGalleryEditor } from "@/editor/components/landing/image-gallery.editor";
|
||||
import { newsletterCtaEditor } from "@/editor/components/landing/newsletter-cta.editor";
|
||||
import { logosEditor } from "@/editor/components/logos/logos.editor";
|
||||
import { ctaEditor } from "@/editor/components/cta/cta.editor";
|
||||
import { faqEditor } from "@/editor/components/faq/faq.editor";
|
||||
|
||||
import Root from "./root";
|
||||
import type { UserConfig } from "./types";
|
||||
import { initialData } from "./initial-data";
|
||||
|
||||
export type CreateConfigOptions = {
|
||||
domain: string;
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export function createConfig({ domain, token }: CreateConfigOptions): UserConfig {
|
||||
const productField = createFieldShopifyProduct({
|
||||
storeDomain: domain,
|
||||
storefrontAccessToken: token ?? undefined,
|
||||
}) as any;
|
||||
const collectionField = createFieldShopifyCollection({
|
||||
storeDomain: domain,
|
||||
storefrontAccessToken: token ?? undefined,
|
||||
}) as any;
|
||||
|
||||
return {
|
||||
root: Root,
|
||||
categories: {
|
||||
navigation: { title: "Navigation" },
|
||||
hero: { title: "Hero & Banners" },
|
||||
commerce: { title: "Commerce" },
|
||||
content: { title: "Content" },
|
||||
footer: { title: "Footer" },
|
||||
},
|
||||
components: {
|
||||
navigation: navigationEditor,
|
||||
hero: heroEditor,
|
||||
banner: bannerEditor,
|
||||
"featured-product": createFeaturedProductEditor({ productField }),
|
||||
"products-grid": createProductsGridEditor({ collectionField }),
|
||||
"products-carousel": createProductsCarouselEditor({ collectionField }),
|
||||
"collection-grid": collectionGridEditor,
|
||||
collection: createCollectionEditor({ collectionField }),
|
||||
"product-details": createProductDetailsEditor({ productField }),
|
||||
"recommended-products": createRecommendedProductsEditor({ productField }),
|
||||
features: featuresEditor,
|
||||
testimonials: testimonialsEditor,
|
||||
"image-gallery": imageGalleryEditor,
|
||||
"newsletter-cta": newsletterCtaEditor,
|
||||
logos: logosEditor,
|
||||
cta: ctaEditor,
|
||||
faq: faqEditor,
|
||||
footer: footerEditor,
|
||||
} as any,
|
||||
};
|
||||
}
|
||||
|
||||
function toBase64(s: string): string {
|
||||
if (typeof btoa === "function") return btoa(unescape(encodeURIComponent(s)));
|
||||
return Buffer.from(s, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
export const componentKey = toBase64(
|
||||
`commerce-redesign-${JSON.stringify({ initialData })}`,
|
||||
);
|
||||
|
||||
export default createConfig;
|
||||
206
editor/config/initial-data.ts
Normal file
206
editor/config/initial-data.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { UserData } from "./types";
|
||||
|
||||
export const getInitialData = (path: string): Partial<UserData> =>
|
||||
initialData[path] ?? { content: [], root: { props: { title: "Untitled" } } };
|
||||
|
||||
export const initialData: Record<string, UserData> = {
|
||||
"/": {
|
||||
root: {
|
||||
props: {
|
||||
title: "Maison — Considered essentials",
|
||||
headerFont: "Inter",
|
||||
bodyFont: "Inter",
|
||||
},
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "navigation",
|
||||
props: {
|
||||
id: "nav-home",
|
||||
brand: "Maison",
|
||||
links: [
|
||||
{ label: "Shop", href: "/collections" },
|
||||
{ label: "Lookbook", href: "/lookbook" },
|
||||
{ label: "Journal", href: "/journal" },
|
||||
{ label: "About", href: "/about" },
|
||||
],
|
||||
cta: { label: "", href: "" },
|
||||
showSearch: "yes",
|
||||
showCart: "yes",
|
||||
sticky: "yes",
|
||||
tone: "default",
|
||||
bannerText: "",
|
||||
bannerTone: "accent",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "hero",
|
||||
props: {
|
||||
id: "hero-home",
|
||||
tagline: "Spring 2026",
|
||||
heading: "Made for the way you move",
|
||||
subheading:
|
||||
"A considered wardrobe of essentials, cut from natural fibers and designed to last.",
|
||||
primaryCta: { label: "Shop the collection", href: "/collections" },
|
||||
secondaryCta: { label: "Our story", href: "/about" },
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80",
|
||||
align: "left",
|
||||
height: "lg",
|
||||
tone: "dark",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "products-carousel",
|
||||
props: {
|
||||
id: "carousel-home",
|
||||
tagline: "New",
|
||||
heading: "Just dropped",
|
||||
subheading: "Fresh additions to the lineup.",
|
||||
limit: 12,
|
||||
slidesPerView: "4",
|
||||
ctaLabel: "Shop new",
|
||||
ctaHref: "/collections/new",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "featured-product",
|
||||
props: {
|
||||
id: "featured-home",
|
||||
handle: "",
|
||||
tagline: "Featured",
|
||||
ctaLabel: "Add to bag",
|
||||
align: "left",
|
||||
tone: "muted",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "collection-grid",
|
||||
props: {
|
||||
id: "collections-home",
|
||||
tagline: "Shop by collection",
|
||||
heading: "Curated edits",
|
||||
subheading: "Bundles built around the way you actually live.",
|
||||
layout: "tiles",
|
||||
limit: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "features",
|
||||
props: {
|
||||
id: "features-home",
|
||||
tagline: "Why us",
|
||||
heading: "Built with intention",
|
||||
subheading: "A small set of values that shape every piece we make.",
|
||||
columns: "3",
|
||||
items: [
|
||||
{
|
||||
title: "Natural fibers",
|
||||
body: "Linen, organic cotton, and merino — sourced from mills with traceable supply chains.",
|
||||
},
|
||||
{
|
||||
title: "Small batches",
|
||||
body: "Made in considered quantities so nothing goes to waste.",
|
||||
},
|
||||
{
|
||||
title: "Built to last",
|
||||
body: "Reinforced seams and finishes that age into something better.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "testimonials",
|
||||
props: {
|
||||
id: "testimonials-home",
|
||||
tagline: "Reviews",
|
||||
heading: "What our customers say",
|
||||
items: [
|
||||
{
|
||||
quote:
|
||||
"I've been wearing the same linen shirt for two summers now and it's somehow gotten better with every wash.",
|
||||
author: "Mara K.",
|
||||
role: "Berlin",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Considered cuts, neutral palette, real fabric. Exactly what I want when I'm getting dressed in the dark.",
|
||||
author: "Theo R.",
|
||||
role: "Brooklyn",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "newsletter-cta",
|
||||
props: {
|
||||
id: "newsletter-home",
|
||||
tagline: "Stay in the loop",
|
||||
heading: "Letters from the studio",
|
||||
subheading:
|
||||
"New collections, mill stories, and the occasional invitation. Twice a month.",
|
||||
buttonLabel: "Subscribe",
|
||||
endpoint: "",
|
||||
imageUrl:
|
||||
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
|
||||
layout: "split",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "footer",
|
||||
props: {
|
||||
id: "footer-home",
|
||||
brand: "Maison",
|
||||
tagline:
|
||||
"Considered essentials, made in small batches and built to last beyond the season.",
|
||||
columns: [
|
||||
{
|
||||
title: "Shop",
|
||||
links: [
|
||||
{ label: "All", href: "/collections" },
|
||||
{ label: "New", href: "/collections/new" },
|
||||
{ label: "Best sellers", href: "/collections/best" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About",
|
||||
links: [
|
||||
{ label: "Our story", href: "/about" },
|
||||
{ label: "Materials", href: "/materials" },
|
||||
{ label: "Journal", href: "/journal" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
links: [
|
||||
{ label: "Shipping", href: "/help/shipping" },
|
||||
{ label: "Returns", href: "/help/returns" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
links: [
|
||||
{ label: "Terms", href: "/terms" },
|
||||
{ label: "Privacy", href: "/privacy" },
|
||||
],
|
||||
},
|
||||
],
|
||||
social: [
|
||||
{ label: "Instagram", href: "#" },
|
||||
{ label: "Pinterest", href: "#" },
|
||||
{ label: "TikTok", href: "#" },
|
||||
],
|
||||
showNewsletter: "no",
|
||||
newsletterHeading: "Stay in touch",
|
||||
newsletterEndpoint: "",
|
||||
copyright: "© 2026 Maison. All rights reserved.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
22
editor/config/options.ts
Normal file
22
editor/config/options.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const spacingOptions = [
|
||||
{ label: "8px", value: "8px" },
|
||||
{ label: "16px", value: "16px" },
|
||||
{ label: "24px", value: "24px" },
|
||||
{ label: "32px", value: "32px" },
|
||||
{ label: "40px", value: "40px" },
|
||||
{ label: "48px", value: "48px" },
|
||||
{ label: "56px", value: "56px" },
|
||||
{ label: "64px", value: "64px" },
|
||||
{ label: "72px", value: "72px" },
|
||||
{ label: "80px", value: "80px" },
|
||||
{ label: "88px", value: "88px" },
|
||||
{ label: "96px", value: "96px" },
|
||||
{ label: "104px", value: "104px" },
|
||||
{ label: "112px", value: "112px" },
|
||||
{ label: "120px", value: "120px" },
|
||||
{ label: "128px", value: "128px" },
|
||||
{ label: "136px", value: "136px" },
|
||||
{ label: "144px", value: "144px" },
|
||||
{ label: "152px", value: "152px" },
|
||||
{ label: "160px", value: "160px" },
|
||||
];
|
||||
112
editor/config/root.tsx
Normal file
112
editor/config/root.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { DefaultRootProps, RootConfig } from "@reacteditor/core";
|
||||
import { createFieldGoogleFonts } from "@reacteditor/field-google-fonts";
|
||||
import { ThemeProvider, ThemeProps } from "@/editor/theme/ThemeProvider";
|
||||
|
||||
export type RootProps = DefaultRootProps &
|
||||
ThemeProps & {
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
};
|
||||
|
||||
const headerFontField = createFieldGoogleFonts() as any;
|
||||
const bodyFontField = createFieldGoogleFonts() as any;
|
||||
|
||||
export const Root: RootConfig<{
|
||||
props: RootProps;
|
||||
fields: {
|
||||
userField: { type: "userField"; option: boolean };
|
||||
};
|
||||
}> = {
|
||||
defaultProps: {
|
||||
title: "Untitled",
|
||||
headerFont: "Inter",
|
||||
bodyFont: "Inter",
|
||||
// Hex defaults so the color picker reads them and any non-picker
|
||||
// input (typed hex, AI-set value, etc.) is round-trip compatible.
|
||||
primaryColor: "#0a0a0a",
|
||||
accentColor: "#f5f5f5",
|
||||
bgColor: "#ffffff",
|
||||
fgColor: "#0a0a0a",
|
||||
mutedColor: "#f5f5f5",
|
||||
roundedness: "md",
|
||||
shadowLevel: "sm",
|
||||
maxWidth: "xl",
|
||||
},
|
||||
fields: {
|
||||
title: { label: "Page title", type: "text" },
|
||||
description: { label: "Description", type: "textarea" },
|
||||
ogImage: { label: "OG image URL", type: "text" },
|
||||
headerFont: { label: "Header font", ...headerFontField },
|
||||
bodyFont: { label: "Body font", ...bodyFontField },
|
||||
primaryColor: { label: "Primary color", type: "color", placeholder: "#0a0a0a" },
|
||||
accentColor: { label: "Accent color", type: "color", placeholder: "#f5f5f5" },
|
||||
bgColor: { label: "Background color", type: "color", placeholder: "#ffffff" },
|
||||
fgColor: { label: "Foreground color", type: "color", placeholder: "#0a0a0a" },
|
||||
mutedColor: { label: "Muted color", type: "color", placeholder: "#f5f5f5" },
|
||||
roundedness: {
|
||||
label: "Roundedness",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Small", value: "sm" },
|
||||
{ label: "Medium", value: "md" },
|
||||
{ label: "Large", value: "lg" },
|
||||
{ label: "Extra large", value: "xl" },
|
||||
{ label: "Full (pill)", value: "full" },
|
||||
],
|
||||
},
|
||||
shadowLevel: {
|
||||
label: "Shadow level",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Small", value: "sm" },
|
||||
{ label: "Medium", value: "md" },
|
||||
{ label: "Large", value: "lg" },
|
||||
{ label: "Extra large", value: "xl" },
|
||||
],
|
||||
},
|
||||
maxWidth: {
|
||||
label: "Max width",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Small", value: "sm" },
|
||||
{ label: "Medium", value: "md" },
|
||||
{ label: "Large", value: "lg" },
|
||||
{ label: "Extra large", value: "xl" },
|
||||
{ label: "2X large", value: "2xl" },
|
||||
{ label: "Full bleed", value: "full" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: ({
|
||||
children,
|
||||
headerFont,
|
||||
bodyFont,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
bgColor,
|
||||
fgColor,
|
||||
mutedColor,
|
||||
roundedness,
|
||||
shadowLevel,
|
||||
}) => {
|
||||
return (
|
||||
<ThemeProvider
|
||||
headerFont={headerFont}
|
||||
bodyFont={bodyFont}
|
||||
primaryColor={primaryColor}
|
||||
accentColor={accentColor}
|
||||
bgColor={bgColor}
|
||||
fgColor={fgColor}
|
||||
mutedColor={mutedColor}
|
||||
roundedness={roundedness}
|
||||
shadowLevel={shadowLevel}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default Root;
|
||||
61
editor/config/types.ts
Normal file
61
editor/config/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Config, Data } from "@reacteditor/core";
|
||||
|
||||
import { HeroProps } from "@/editor/components/hero/hero";
|
||||
import { LogosProps } from "@/editor/components/logos/logos";
|
||||
import { FeaturesProps } from "@/editor/components/features/features";
|
||||
import { TestimonialsProps } from "@/editor/components/testimonials/testimonials";
|
||||
import { CTAProps } from "@/editor/components/cta/cta";
|
||||
import { FAQProps } from "@/editor/components/faq/faq";
|
||||
import { NavigationProps } from "@/editor/components/navigation/navigation";
|
||||
import { FooterProps } from "@/editor/components/footer/footer";
|
||||
|
||||
import { ProductsGridProps } from "@/editor/components/commerce/products-grid";
|
||||
import { ProductsCarouselProps } from "@/editor/components/commerce/products-carousel";
|
||||
import { CollectionGridProps } from "@/editor/components/commerce/collection-grid";
|
||||
import { CollectionProps } from "@/editor/components/commerce/collection";
|
||||
import { ProductDetailsProps } from "@/editor/components/commerce/product-details";
|
||||
import { RecommendedProductsProps } from "@/editor/components/commerce/recommended-products";
|
||||
import { FeaturedProductProps } from "@/editor/components/commerce/featured-product";
|
||||
|
||||
import { BannerProps } from "@/editor/components/landing/banner";
|
||||
import { NewsletterCtaProps } from "@/editor/components/landing/newsletter-cta";
|
||||
import { ImageGalleryProps } from "@/editor/components/landing/image-gallery";
|
||||
|
||||
import { RootProps } from "./root";
|
||||
|
||||
export type { RootProps } from "./root";
|
||||
|
||||
export type Components = {
|
||||
navigation: NavigationProps;
|
||||
hero: HeroProps;
|
||||
banner: BannerProps;
|
||||
"featured-product": FeaturedProductProps;
|
||||
"products-grid": ProductsGridProps;
|
||||
"products-carousel": ProductsCarouselProps;
|
||||
"collection-grid": CollectionGridProps;
|
||||
collection: CollectionProps;
|
||||
"product-details": ProductDetailsProps;
|
||||
"recommended-products": RecommendedProductsProps;
|
||||
features: FeaturesProps;
|
||||
testimonials: TestimonialsProps;
|
||||
"image-gallery": ImageGalleryProps;
|
||||
"newsletter-cta": NewsletterCtaProps;
|
||||
logos: LogosProps;
|
||||
cta: CTAProps;
|
||||
faq: FAQProps;
|
||||
footer: FooterProps;
|
||||
};
|
||||
|
||||
export type UserConfig = Config<{
|
||||
components: Components;
|
||||
root: RootProps;
|
||||
categories: ["navigation", "hero", "commerce", "content", "footer"];
|
||||
fields: {
|
||||
userField: {
|
||||
type: "userField";
|
||||
option: boolean;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export type UserData = Data<Components, RootProps>;
|
||||
301
editor/contexts/shopify-context.tsx
Normal file
301
editor/contexts/shopify-context.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
createCart,
|
||||
getCart,
|
||||
addCartLines,
|
||||
removeCartLines,
|
||||
updateCartLines,
|
||||
} from '@/editor/hooks/use-shopify-cart';
|
||||
import { setShopifyCredentials } from '@/editor/services/shopify/client';
|
||||
|
||||
const CART_ID_KEY = 'cartId';
|
||||
|
||||
interface CartLine {
|
||||
id: string;
|
||||
quantity: number;
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
price: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
selectedOptions?: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
product: {
|
||||
title: string;
|
||||
handle?: string;
|
||||
images: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
url: string;
|
||||
altText: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ShopifyCart {
|
||||
id: string;
|
||||
lines: {
|
||||
edges: Array<{
|
||||
node: CartLine;
|
||||
}>;
|
||||
};
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
subtotalAmount?: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
totalTaxAmount?: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
};
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
interface CartContextType {
|
||||
isOpen: boolean;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
toggleCart: () => void;
|
||||
cartId: string | null;
|
||||
cart: ShopifyCart | null;
|
||||
items: CartLine[];
|
||||
itemCount: number;
|
||||
totalAmount: number;
|
||||
checkoutUrl: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
addItem: (variantId: string, quantity?: number) => Promise<ShopifyCart>;
|
||||
removeItem: (lineId: string) => Promise<ShopifyCart>;
|
||||
updateItemQuantity: (lineId: string, quantity: number) => Promise<ShopifyCart>;
|
||||
refreshCart: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const CartContext = createContext<CartContextType | null>(null);
|
||||
|
||||
type ShopifyContextValue = {
|
||||
domain: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const ShopifyConfigContext = createContext<ShopifyContextValue | null>(null);
|
||||
|
||||
export function useShopifyConfig() {
|
||||
const ctx = useContext(ShopifyConfigContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useShopifyConfig must be used inside <ShopifyProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const ShopifyProvider: React.FC<{
|
||||
domain: string;
|
||||
token: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ domain, token, children }) => {
|
||||
console.log('[ShopifyProvider] creds', {
|
||||
domain,
|
||||
hasToken: !!token,
|
||||
tokenPreview: token ? `${token.slice(0, 4)}…${token.slice(-4)}` : null,
|
||||
});
|
||||
|
||||
// Sync creds into the module-level store synchronously so any render-time
|
||||
// call (incl. SSR) reads the right domain/token.
|
||||
setShopifyCredentials({ domain, token });
|
||||
|
||||
const config = useMemo(() => ({ domain, token }), [domain, token]);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cartId, setCartId] = useState<string | null>(null);
|
||||
const [cart, setCart] = useState<ShopifyCart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const storeCartId = useCallback((id: string) => {
|
||||
localStorage.setItem(CART_ID_KEY, id);
|
||||
setCartId(id);
|
||||
}, []);
|
||||
|
||||
const refreshCart = useCallback(async () => {
|
||||
const storedCartId = localStorage.getItem(CART_ID_KEY);
|
||||
if (!storedCartId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fetchedCart = await getCart(storedCartId);
|
||||
if (fetchedCart) {
|
||||
setCart(fetchedCart);
|
||||
setCartId(storedCartId);
|
||||
} else {
|
||||
localStorage.removeItem(CART_ID_KEY);
|
||||
setCartId(null);
|
||||
setCart(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching cart:', err);
|
||||
localStorage.removeItem(CART_ID_KEY);
|
||||
setCartId(null);
|
||||
setCart(null);
|
||||
setError('Failed to fetch cart');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCart();
|
||||
}, [refreshCart]);
|
||||
|
||||
const getOrCreateCart = useCallback(async (): Promise<string> => {
|
||||
if (cartId) return cartId;
|
||||
|
||||
const storedCartId = localStorage.getItem(CART_ID_KEY);
|
||||
if (storedCartId) {
|
||||
setCartId(storedCartId);
|
||||
return storedCartId;
|
||||
}
|
||||
|
||||
try {
|
||||
const newCart = await createCart();
|
||||
setCart(newCart);
|
||||
storeCartId(newCart.id);
|
||||
return newCart.id;
|
||||
} catch (err) {
|
||||
console.error('Failed to create cart:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [cartId, storeCartId]);
|
||||
|
||||
const openCart = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeCart = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleCart = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(async (variantId: string, quantity: number = 1): Promise<ShopifyCart> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const currentCartId = await getOrCreateCart();
|
||||
const updatedCart = await addCartLines(currentCartId, [
|
||||
{ merchandiseId: variantId, quantity },
|
||||
]);
|
||||
|
||||
setCart(updatedCart);
|
||||
return updatedCart;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to add item to cart';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getOrCreateCart]);
|
||||
|
||||
const removeItem = useCallback(async (lineId: string): Promise<ShopifyCart> => {
|
||||
if (!cartId) {
|
||||
throw new Error('No cart exists');
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const updatedCart = await removeCartLines(cartId, [lineId]);
|
||||
setCart(updatedCart);
|
||||
return updatedCart;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to remove item from cart';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cartId]);
|
||||
|
||||
const updateItemQuantity = useCallback(async (lineId: string, quantity: number): Promise<ShopifyCart> => {
|
||||
if (!cartId) {
|
||||
throw new Error('No cart exists');
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
return removeItem(lineId);
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const updatedCart = await updateCartLines(cartId, [
|
||||
{ id: lineId, quantity },
|
||||
]);
|
||||
setCart(updatedCart);
|
||||
return updatedCart;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update item quantity';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cartId, removeItem]);
|
||||
|
||||
const items = cart?.lines?.edges?.map((edge) => edge.node) ?? [];
|
||||
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
|
||||
const checkoutUrl = cart?.checkoutUrl ?? null;
|
||||
|
||||
return (
|
||||
<ShopifyConfigContext.Provider value={config}>
|
||||
<CartContext.Provider value={{
|
||||
isOpen,
|
||||
openCart,
|
||||
closeCart,
|
||||
toggleCart,
|
||||
cartId,
|
||||
cart,
|
||||
items,
|
||||
itemCount,
|
||||
totalAmount,
|
||||
checkoutUrl,
|
||||
loading,
|
||||
error,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
refreshCart,
|
||||
}}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
</ShopifyConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Alias for backwards compatibility
|
||||
export const CartProvider = ShopifyProvider;
|
||||
|
||||
export default ShopifyProvider;
|
||||
137
editor/graphql/cart.js
Normal file
137
editor/graphql/cart.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// Cart Fragment for consistent cart data
|
||||
const CartFragment = `
|
||||
fragment CartFragment on Cart {
|
||||
id
|
||||
checkoutUrl
|
||||
totalQuantity
|
||||
cost {
|
||||
subtotalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
totalTaxAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
lines(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
quantity
|
||||
cost {
|
||||
totalAmount {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
id
|
||||
title
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
vendor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Create a new cart
|
||||
export const CREATE_CART_MUTATION = `
|
||||
${CartFragment}
|
||||
mutation CreateCart($lines: [CartLineInput!]) {
|
||||
cartCreate(input: { lines: $lines }) {
|
||||
cart {
|
||||
...CartFragment
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Add lines to cart
|
||||
export const ADD_CART_LINES_MUTATION = `
|
||||
${CartFragment}
|
||||
mutation AddCartLines($cartId: ID!, $lines: [CartLineInput!]!) {
|
||||
cartLinesAdd(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
...CartFragment
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Update cart lines
|
||||
export const UPDATE_CART_LINES_MUTATION = `
|
||||
${CartFragment}
|
||||
mutation UpdateCartLines($cartId: ID!, $lines: [CartLineUpdateInput!]!) {
|
||||
cartLinesUpdate(cartId: $cartId, lines: $lines) {
|
||||
cart {
|
||||
...CartFragment
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Remove lines from cart
|
||||
export const REMOVE_CART_LINES_MUTATION = `
|
||||
${CartFragment}
|
||||
mutation RemoveCartLines($cartId: ID!, $lineIds: [ID!]!) {
|
||||
cartLinesRemove(cartId: $cartId, lineIds: $lineIds) {
|
||||
cart {
|
||||
...CartFragment
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Get cart by ID
|
||||
export const GET_CART_QUERY = `
|
||||
${CartFragment}
|
||||
query GetCart($cartId: ID!) {
|
||||
cart(id: $cartId) {
|
||||
...CartFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
61
editor/graphql/collections.js
Normal file
61
editor/graphql/collections.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ProductFragment } from './products.js';
|
||||
|
||||
// Get all collections
|
||||
export const GET_COLLECTIONS_QUERY = `
|
||||
query GetCollections($first: Int!) {
|
||||
collections(first: $first) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
descriptionHtml
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Get products in a collection
|
||||
export const GET_COLLECTION_PRODUCTS_QUERY = `
|
||||
${ProductFragment}
|
||||
query GetCollectionProducts($handle: String!, $first: Int!, $sortKey: ProductCollectionSortKeys, $reverse: Boolean) {
|
||||
collection(handle: $handle) {
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
descriptionHtml
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
products(first: $first, sortKey: $sortKey, reverse: $reverse) {
|
||||
edges {
|
||||
node {
|
||||
...ProductFragment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
116
editor/graphql/products.js
Normal file
116
editor/graphql/products.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// Product Fragment for consistent product data
|
||||
export const ProductFragment = `
|
||||
fragment ProductFragment on Product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
description
|
||||
descriptionHtml
|
||||
vendor
|
||||
productType
|
||||
tags
|
||||
availableForSale
|
||||
priceRange {
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
maxVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
compareAtPriceRange {
|
||||
minVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
maxVariantPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
images(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
variants(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
availableForSale
|
||||
selectedOptions {
|
||||
name
|
||||
value
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
compareAtPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
options {
|
||||
id
|
||||
name
|
||||
values
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Get multiple products
|
||||
export const GET_PRODUCTS_QUERY = `
|
||||
${ProductFragment}
|
||||
query GetProducts($first: Int!, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
|
||||
products(first: $first, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
||||
edges {
|
||||
node {
|
||||
...ProductFragment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Get a single product by handle
|
||||
export const GET_PRODUCT_QUERY = `
|
||||
${ProductFragment}
|
||||
query GetProduct($handle: String!) {
|
||||
product(handle: $handle) {
|
||||
...ProductFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Get product recommendations
|
||||
export const QUERY_PRODUCT_RECOMMENDATIONS = `
|
||||
${ProductFragment}
|
||||
query GetProductRecommendations($productId: ID!) {
|
||||
productRecommendations(productId: $productId) {
|
||||
...ProductFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
165
editor/hooks/use-shopify-cart.ts
Normal file
165
editor/hooks/use-shopify-cart.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { shopifyFetch, SHOPIFY_STORE_DOMAIN } from '@/editor/services/shopify/client';
|
||||
import { CartContext } from '@/editor/contexts/shopify-context';
|
||||
import {
|
||||
CREATE_CART_MUTATION,
|
||||
ADD_CART_LINES_MUTATION,
|
||||
UPDATE_CART_LINES_MUTATION,
|
||||
REMOVE_CART_LINES_MUTATION,
|
||||
GET_CART_QUERY,
|
||||
} from '@/editor/graphql/cart';
|
||||
|
||||
export interface CartLineInput {
|
||||
merchandiseId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface CartLineUpdateInput {
|
||||
id: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CartLine {
|
||||
id: string;
|
||||
quantity: number;
|
||||
cost: {
|
||||
totalAmount: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
};
|
||||
merchandise: {
|
||||
id: string;
|
||||
title: string;
|
||||
selectedOptions: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
price: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
image?: {
|
||||
id: string;
|
||||
url: string;
|
||||
altText?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
product: {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
vendor?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
id: string;
|
||||
checkoutUrl: string;
|
||||
totalQuantity: number;
|
||||
cost: {
|
||||
subtotalAmount: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
totalAmount: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
totalTaxAmount?: {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
};
|
||||
lines: {
|
||||
edges: Array<{
|
||||
node: CartLine;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Create a new cart (optionally with initial items)
|
||||
export async function createCart(lines: CartLineInput[] = []): Promise<Cart> {
|
||||
const response = await shopifyFetch({
|
||||
query: CREATE_CART_MUTATION,
|
||||
variables: { lines: lines.length > 0 ? lines : null },
|
||||
});
|
||||
|
||||
if (response.data.cartCreate.userErrors.length > 0) {
|
||||
throw new Error(response.data.cartCreate.userErrors[0].message);
|
||||
}
|
||||
|
||||
return response.data.cartCreate.cart;
|
||||
}
|
||||
|
||||
// Add items to cart
|
||||
export async function addCartLines(cartId: string, lines: CartLineInput[]): Promise<Cart> {
|
||||
const response = await shopifyFetch({
|
||||
query: ADD_CART_LINES_MUTATION,
|
||||
variables: { cartId, lines },
|
||||
});
|
||||
|
||||
if (response.data.cartLinesAdd.userErrors.length > 0) {
|
||||
throw new Error(response.data.cartLinesAdd.userErrors[0].message);
|
||||
}
|
||||
|
||||
return response.data.cartLinesAdd.cart;
|
||||
}
|
||||
|
||||
// Update cart line quantities
|
||||
export async function updateCartLines(cartId: string, lines: CartLineUpdateInput[]): Promise<Cart> {
|
||||
const response = await shopifyFetch({
|
||||
query: UPDATE_CART_LINES_MUTATION,
|
||||
variables: { cartId, lines },
|
||||
});
|
||||
|
||||
if (response.data.cartLinesUpdate.userErrors.length > 0) {
|
||||
throw new Error(response.data.cartLinesUpdate.userErrors[0].message);
|
||||
}
|
||||
|
||||
return response.data.cartLinesUpdate.cart;
|
||||
}
|
||||
|
||||
// Remove items from cart
|
||||
export async function removeCartLines(cartId: string, lineIds: string[]): Promise<Cart> {
|
||||
const response = await shopifyFetch({
|
||||
query: REMOVE_CART_LINES_MUTATION,
|
||||
variables: { cartId, lineIds },
|
||||
});
|
||||
|
||||
if (response.data.cartLinesRemove.userErrors.length > 0) {
|
||||
throw new Error(response.data.cartLinesRemove.userErrors[0].message);
|
||||
}
|
||||
|
||||
return response.data.cartLinesRemove.cart;
|
||||
}
|
||||
|
||||
// Get cart by ID
|
||||
export async function getCart(cartId: string): Promise<Cart | null> {
|
||||
const response = await shopifyFetch({
|
||||
query: GET_CART_QUERY,
|
||||
variables: { cartId },
|
||||
});
|
||||
|
||||
return response.data.cart;
|
||||
}
|
||||
|
||||
// Redirect to Shopify checkout
|
||||
export function redirectToCheckout(checkoutUrl: string): void {
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook to access cart context
|
||||
export const useShopifyCart = () => {
|
||||
const context = useContext(CartContext);
|
||||
if (!context) {
|
||||
throw new Error('useShopifyCart must be used within a ShopifyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
127
editor/hooks/use-shopify-collections.ts
Normal file
127
editor/hooks/use-shopify-collections.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { shopifyFetch } from '@/editor/services/shopify/client';
|
||||
import {
|
||||
GET_COLLECTIONS_QUERY,
|
||||
GET_COLLECTION_PRODUCTS_QUERY,
|
||||
} from '@/editor/graphql/collections';
|
||||
import type { Product } from './use-shopify-products';
|
||||
|
||||
interface CollectionImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
descriptionHtml?: string;
|
||||
image?: CollectionImage;
|
||||
}
|
||||
|
||||
export interface CollectionWithProducts extends Collection {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
interface UseCollectionProductsOptions {
|
||||
first?: number;
|
||||
sortKey?: 'BEST_SELLING' | 'CREATED' | 'PRICE' | 'TITLE';
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
// Fetch all collections
|
||||
export async function getCollections(first = 50): Promise<Collection[]> {
|
||||
const response = await shopifyFetch({
|
||||
query: GET_COLLECTIONS_QUERY,
|
||||
variables: { first },
|
||||
});
|
||||
|
||||
return response.data.collections.edges.map((edge: { node: Collection }) => edge.node);
|
||||
}
|
||||
|
||||
// Fetch products in a collection by handle
|
||||
export async function getCollectionProducts(
|
||||
handle: string,
|
||||
{ first = 50, sortKey = 'BEST_SELLING', reverse = false }: UseCollectionProductsOptions = {}
|
||||
): Promise<CollectionWithProducts | null> {
|
||||
const response = await shopifyFetch({
|
||||
query: GET_COLLECTION_PRODUCTS_QUERY,
|
||||
variables: { handle, first, sortKey, reverse },
|
||||
});
|
||||
|
||||
const collection = response.data.collection;
|
||||
if (!collection) return null;
|
||||
|
||||
return {
|
||||
...collection,
|
||||
products: collection.products.edges.map((edge: { node: Product }) => edge.node),
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for fetching all collections
|
||||
export function useCollections(first = 50) {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCollections = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getCollections(first);
|
||||
setCollections(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching collections:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load collections');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [first]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, [fetchCollections]);
|
||||
|
||||
return { collections, loading, error, refetch: fetchCollections };
|
||||
}
|
||||
|
||||
// Hook for fetching products in a collection
|
||||
export function useCollectionProducts(
|
||||
handle: string | null,
|
||||
options: UseCollectionProductsOptions = {}
|
||||
) {
|
||||
const [collection, setCollection] = useState<CollectionWithProducts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCollection = useCallback(async () => {
|
||||
if (!handle) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getCollectionProducts(handle, options);
|
||||
setCollection(data);
|
||||
if (!data) {
|
||||
setError('Collection not found');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching collection products:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load collection');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handle, options.first, options.sortKey, options.reverse]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollection();
|
||||
}, [fetchCollection]);
|
||||
|
||||
return { collection, loading, error, refetch: fetchCollection };
|
||||
}
|
||||
205
editor/hooks/use-shopify-products.ts
Normal file
205
editor/hooks/use-shopify-products.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { shopifyFetch } from '@/editor/services/shopify/client';
|
||||
import {
|
||||
GET_PRODUCTS_QUERY,
|
||||
GET_PRODUCT_QUERY,
|
||||
QUERY_PRODUCT_RECOMMENDATIONS,
|
||||
} from '@/editor/graphql/products';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
interface ProductPrice {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: ProductPrice;
|
||||
availableForSale: boolean;
|
||||
selectedOptions: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
image?: ProductImage;
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
descriptionHtml?: string;
|
||||
handle: string;
|
||||
images: {
|
||||
edges: Array<{
|
||||
node: ProductImage;
|
||||
}>;
|
||||
};
|
||||
priceRange: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
compareAtPriceRange?: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: ProductVariant;
|
||||
}>;
|
||||
};
|
||||
options: ProductOption[];
|
||||
}
|
||||
|
||||
interface UseProductsOptions {
|
||||
first?: number;
|
||||
query?: string;
|
||||
sortKey?: 'BEST_SELLING' | 'CREATED_AT' | 'PRICE' | 'TITLE';
|
||||
reverse?: boolean;
|
||||
}
|
||||
|
||||
interface UseProductsReturn {
|
||||
products: Product[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Fetch multiple products
|
||||
export async function getProducts({
|
||||
first = 20,
|
||||
query = '',
|
||||
sortKey = 'BEST_SELLING',
|
||||
reverse = false,
|
||||
}: UseProductsOptions = {}): Promise<Product[]> {
|
||||
const response = await shopifyFetch({
|
||||
query: GET_PRODUCTS_QUERY,
|
||||
variables: { first, query, sortKey, reverse },
|
||||
});
|
||||
|
||||
return response.data.products.edges.map((edge: { node: Product }) => edge.node);
|
||||
}
|
||||
|
||||
// Fetch a single product by handle
|
||||
export async function getProduct(handle: string): Promise<Product | null> {
|
||||
const response = await shopifyFetch({
|
||||
query: GET_PRODUCT_QUERY,
|
||||
variables: { handle },
|
||||
});
|
||||
|
||||
return response.data.product;
|
||||
}
|
||||
|
||||
// Fetch product recommendations
|
||||
export async function getProductRecommendations(productId: string): Promise<Product[]> {
|
||||
const response = await shopifyFetch({
|
||||
query: QUERY_PRODUCT_RECOMMENDATIONS,
|
||||
variables: { productId },
|
||||
});
|
||||
|
||||
return response.data.productRecommendations || [];
|
||||
}
|
||||
|
||||
// Hook for fetching multiple products
|
||||
export function useProducts(options: UseProductsOptions = {}): UseProductsReturn {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProducts = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getProducts(options);
|
||||
setProducts(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load products');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [options.first, options.query, options.sortKey, options.reverse]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [fetchProducts]);
|
||||
|
||||
return { products, loading, error, refetch: fetchProducts };
|
||||
}
|
||||
|
||||
// Hook for fetching a single product
|
||||
export function useProduct(handle: string | null) {
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProduct = useCallback(async () => {
|
||||
if (!handle) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getProduct(handle);
|
||||
setProduct(data);
|
||||
if (!data) {
|
||||
setError('Product not found');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching product:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load product');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProduct();
|
||||
}, [fetchProduct]);
|
||||
|
||||
return { product, loading, error, refetch: fetchProduct };
|
||||
}
|
||||
|
||||
// Hook for fetching product recommendations
|
||||
export function useProductRecommendations(productId: string | null) {
|
||||
const [recommendations, setRecommendations] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRecommendations = useCallback(async () => {
|
||||
if (!productId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getProductRecommendations(productId);
|
||||
setRecommendations(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recommendations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, [fetchRecommendations]);
|
||||
|
||||
return { recommendations, loading, error, refetch: fetchRecommendations };
|
||||
}
|
||||
34
editor/lib/resolve-editor-path.ts
Normal file
34
editor/lib/resolve-editor-path.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
const TEMPLATE_PATTERNS: { key: string; prefix: string; param: string }[] = [
|
||||
{ key: "/products/*", prefix: "/products/", param: "handle" },
|
||||
{ key: "/collections/*", prefix: "/collections/", param: "handle" },
|
||||
];
|
||||
|
||||
export type ResolvedEditorPath = {
|
||||
isEdit: boolean;
|
||||
path: string;
|
||||
templateKey: string | null;
|
||||
params: Record<string, string>;
|
||||
};
|
||||
|
||||
const resolveEditorPath = (editorPath: string[] = []): ResolvedEditorPath => {
|
||||
const isEdit =
|
||||
editorPath.length > 0 && editorPath[editorPath.length - 1] === "edit";
|
||||
|
||||
const segments = isEdit ? editorPath.slice(0, -1) : editorPath;
|
||||
const path = segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
||||
|
||||
for (const { key, prefix, param } of TEMPLATE_PATTERNS) {
|
||||
if (path.startsWith(prefix) && path.length > prefix.length) {
|
||||
return {
|
||||
isEdit,
|
||||
path,
|
||||
templateKey: key,
|
||||
params: { [param]: path.slice(prefix.length) },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isEdit, path, templateKey: null, params: {} };
|
||||
};
|
||||
|
||||
export default resolveEditorPath;
|
||||
54
editor/lib/use-demo-data.ts
Normal file
54
editor/lib/use-demo-data.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import config, { componentKey } from "../config";
|
||||
import { getInitialData, initialData } from "../config/initial-data";
|
||||
import { Metadata, resolveAllData } from "@reacteditor/core";
|
||||
import { Components, UserData } from "../config/types";
|
||||
import { RootProps } from "../config/root";
|
||||
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
export const useDemoData = ({
|
||||
path,
|
||||
isEdit,
|
||||
metadata = {},
|
||||
}: {
|
||||
path: string;
|
||||
isEdit: boolean;
|
||||
metadata?: Metadata;
|
||||
}) => {
|
||||
// unique b64 key that updates each time we add / remove components
|
||||
const key = `react-editor-demo:${componentKey}:${path}`;
|
||||
|
||||
const [data] = useState<Partial<UserData>>(() => {
|
||||
if (isBrowser) {
|
||||
const dataStr = localStorage.getItem(key);
|
||||
|
||||
if (dataStr) {
|
||||
return JSON.parse(dataStr);
|
||||
}
|
||||
|
||||
return getInitialData(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Normally this would happen on the server, but we can't
|
||||
// do that because we're using local storage as a database
|
||||
const [resolvedData, setResolvedData] = useState<Partial<UserData>>(data);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !isEdit) {
|
||||
resolveAllData<Components, RootProps>(data, config, metadata).then(
|
||||
setResolvedData
|
||||
);
|
||||
}
|
||||
}, [data, isEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
const title = data?.root?.props?.title || data?.root?.title;
|
||||
document.title = title || "";
|
||||
}
|
||||
}, [data, isEdit]);
|
||||
|
||||
return { data, resolvedData, key };
|
||||
};
|
||||
11
editor/lib/utils.ts
Normal file
11
editor/lib/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function truncate(text: string, max = 80): string {
|
||||
if (!text) return "";
|
||||
return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
|
||||
}
|
||||
63
editor/services/shopify/client.ts
Normal file
63
editor/services/shopify/client.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const SHOPIFY_API_VERSION = '2026-04';
|
||||
|
||||
type Credentials = {
|
||||
domain: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
let currentCreds: Partial<Credentials> = {};
|
||||
|
||||
export function setShopifyCredentials(creds: Credentials) {
|
||||
currentCreds = { domain: creds.domain, token: creds.token };
|
||||
}
|
||||
|
||||
export function getShopifyCredentials(): Partial<Credentials> {
|
||||
return currentCreds;
|
||||
}
|
||||
|
||||
export const SHOPIFY_STORE_DOMAIN = currentCreds.domain ?? '';
|
||||
|
||||
export async function shopifyFetch<T = any>({
|
||||
query,
|
||||
variables = {},
|
||||
credentials,
|
||||
}: {
|
||||
query: string;
|
||||
variables?: Record<string, any>;
|
||||
credentials?: Partial<Credentials>;
|
||||
}): Promise<{ data: T; errors?: any[] }> {
|
||||
const domain = credentials?.domain ?? currentCreds.domain;
|
||||
const token = credentials?.token ?? currentCreds.token;
|
||||
const apiVersion = SHOPIFY_API_VERSION;
|
||||
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
'[shopifyFetch] missing domain. Wrap your tree in <ShopifyProvider domain="..."> or call setShopifyCredentials() before rendering.',
|
||||
);
|
||||
}
|
||||
|
||||
const url = `https://${domain}/api/${apiVersion}/graphql.json`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (token) {
|
||||
headers['X-Shopify-Storefront-Access-Token'] = token;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Shopify HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (json.errors) {
|
||||
throw new Error(`Shopify GraphQL errors: ${JSON.stringify(json.errors)}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
163
editor/theme/ThemeProvider.tsx
Normal file
163
editor/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
export type ThemeProps = {
|
||||
headerFont?: string;
|
||||
bodyFont?: string;
|
||||
primaryColor?: string;
|
||||
primaryForegroundColor?: string;
|
||||
accentColor?: string;
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
mutedColor?: string;
|
||||
mutedForegroundColor?: string;
|
||||
borderColor?: string;
|
||||
roundedness?: "none" | "sm" | "md" | "lg" | "xl" | "full";
|
||||
shadowLevel?: "none" | "sm" | "md" | "lg" | "xl";
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
};
|
||||
|
||||
const radiusMap: Record<NonNullable<ThemeProps["roundedness"]>, string> = {
|
||||
none: "0px",
|
||||
sm: "0.25rem",
|
||||
md: "0.5rem",
|
||||
lg: "0.75rem",
|
||||
xl: "1rem",
|
||||
full: "9999px",
|
||||
};
|
||||
|
||||
const shadowMap: Record<NonNullable<ThemeProps["shadowLevel"]>, string> = {
|
||||
none: "0 0 #0000",
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)",
|
||||
xl: "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)",
|
||||
};
|
||||
|
||||
function googleFontsHref(headerFont?: string, bodyFont?: string): string | null {
|
||||
const fonts = [headerFont, bodyFont].filter(
|
||||
(f): f is string => !!f && f !== "system-ui"
|
||||
);
|
||||
if (fonts.length === 0) return null;
|
||||
const families = Array.from(new Set(fonts))
|
||||
.map((f) => `family=${encodeURIComponent(f)}:wght@400;500;600;700`)
|
||||
.join("&");
|
||||
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
headerFont,
|
||||
bodyFont,
|
||||
primaryColor,
|
||||
primaryForegroundColor,
|
||||
accentColor,
|
||||
bgColor,
|
||||
fgColor,
|
||||
mutedColor,
|
||||
mutedForegroundColor,
|
||||
borderColor,
|
||||
roundedness,
|
||||
shadowLevel,
|
||||
children,
|
||||
}: ThemeProps & { children?: React.ReactNode }) {
|
||||
// Recompute CSS-variable map only when a relevant prop changes.
|
||||
const cssVars = useMemo<Record<string, string>>(() => {
|
||||
const vars: Record<string, string> = {};
|
||||
if (primaryColor) vars["--primary"] = primaryColor;
|
||||
if (primaryForegroundColor) vars["--primary-foreground"] = primaryForegroundColor;
|
||||
if (accentColor) vars["--accent"] = accentColor;
|
||||
if (bgColor) vars["--background"] = bgColor;
|
||||
if (fgColor) vars["--foreground"] = fgColor;
|
||||
if (mutedColor) vars["--muted"] = mutedColor;
|
||||
if (mutedForegroundColor) vars["--muted-foreground"] = mutedForegroundColor;
|
||||
if (borderColor) vars["--border"] = borderColor;
|
||||
if (roundedness) vars["--radius"] = radiusMap[roundedness];
|
||||
if (shadowLevel) vars["--shadow"] = shadowMap[shadowLevel];
|
||||
if (headerFont) vars["--font-header"] = `"${headerFont}", system-ui, sans-serif`;
|
||||
if (bodyFont) vars["--font-body"] = `"${bodyFont}", system-ui, sans-serif`;
|
||||
return vars;
|
||||
}, [
|
||||
headerFont,
|
||||
bodyFont,
|
||||
primaryColor,
|
||||
primaryForegroundColor,
|
||||
accentColor,
|
||||
bgColor,
|
||||
fgColor,
|
||||
mutedColor,
|
||||
mutedForegroundColor,
|
||||
borderColor,
|
||||
roundedness,
|
||||
shadowLevel,
|
||||
]);
|
||||
|
||||
// Imperatively push every CSS var onto :root inside the host document
|
||||
// (which is the iframe's document for the editor preview, and the page
|
||||
// <html> for the published render). This guarantees descendants pick up
|
||||
// updates even if React's style-prop diffing missed something or the
|
||||
// base-layer rules need access via :root inheritance.
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const doc =
|
||||
rootRef.current?.ownerDocument ??
|
||||
(typeof document !== "undefined" ? document : null);
|
||||
if (!doc) return;
|
||||
const target = doc.documentElement;
|
||||
const previous: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(cssVars)) {
|
||||
previous[key] = target.style.getPropertyValue(key);
|
||||
target.style.setProperty(key, value);
|
||||
}
|
||||
return () => {
|
||||
// Restore prior values so unmount doesn't leak our overrides.
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value) target.style.setProperty(key, value);
|
||||
else target.style.removeProperty(key);
|
||||
}
|
||||
};
|
||||
}, [cssVars]);
|
||||
|
||||
const fontsHref = useMemo(
|
||||
() => googleFontsHref(headerFont, bodyFont),
|
||||
[headerFont, bodyFont],
|
||||
);
|
||||
|
||||
// Plain CSS rules — applied directly, no Tailwind CDN runtime needed.
|
||||
// Tailwind preflight resets h1..h6 to font-family: inherit, which would
|
||||
// make headings pick up the body font. We override that here using the
|
||||
// `--font-header` CSS var ThemeProvider sets per-page.
|
||||
const css = `
|
||||
body, p, span, a, li, button, input, textarea, select {
|
||||
font-family: var(--font-body), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-header), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
`;
|
||||
|
||||
// Tailwind theme directives — only useful if/when the CDN compiles
|
||||
// font-heading / font-body utilities. The plain CSS above handles the
|
||||
// common case so headers always render with the right font.
|
||||
const tailwindCss = `
|
||||
@theme {
|
||||
--font-family-heading: var(--font-header), system-ui, -apple-system, sans-serif;
|
||||
--font-family-body: var(--font-body), system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{fontsHref ? <link rel="stylesheet" href={fontsHref} /> : null}
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
<style type="text/tailwindcss" dangerouslySetInnerHTML={{ __html: tailwindCss }} />
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-theme-root
|
||||
style={cssVars as React.CSSProperties}
|
||||
className="flex min-h-screen flex-col bg-background text-foreground"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
editor/theme/Typography.tsx
Normal file
94
editor/theme/Typography.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/editor/lib/utils";
|
||||
|
||||
export type TypographyVariant =
|
||||
| "h1"
|
||||
| "h2"
|
||||
| "h3"
|
||||
| "h4"
|
||||
| "h5"
|
||||
| "h6"
|
||||
| "subtitle1"
|
||||
| "subtitle2"
|
||||
| "body1"
|
||||
| "body2"
|
||||
| "caption";
|
||||
|
||||
const sizeClasses: Record<TypographyVariant, string> = {
|
||||
h1: "text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tight leading-[1.05]",
|
||||
h2: "text-4xl md:text-5xl font-semibold tracking-tight leading-[1.1]",
|
||||
h3: "text-3xl md:text-4xl font-semibold tracking-tight leading-tight",
|
||||
h4: "text-2xl md:text-3xl font-semibold tracking-tight leading-snug",
|
||||
h5: "text-xl md:text-2xl font-semibold leading-snug",
|
||||
h6: "text-lg md:text-xl font-semibold leading-snug",
|
||||
subtitle1: "text-lg md:text-xl leading-relaxed text-muted-foreground",
|
||||
subtitle2: "text-base md:text-lg leading-relaxed text-muted-foreground",
|
||||
body1: "text-lg leading-relaxed",
|
||||
body2: "text-base leading-relaxed",
|
||||
caption: "text-sm leading-relaxed text-muted-foreground",
|
||||
};
|
||||
|
||||
// Inline-style fallbacks so headings size correctly even when Tailwind
|
||||
// preflight resets <h1>..<h6> to font-size: inherit and the iframe CDN
|
||||
// hasn't compiled utility classes yet. The Tailwind classes above still
|
||||
// apply on top once available (responsive breakpoints, leading, etc.).
|
||||
const sizeStyles: Record<TypographyVariant, React.CSSProperties> = {
|
||||
h1: { fontSize: "clamp(2.5rem, 6vw, 4.5rem)", lineHeight: 1.05, fontWeight: 600, letterSpacing: "-0.02em" },
|
||||
h2: { fontSize: "clamp(2rem, 4vw, 3rem)", lineHeight: 1.1, fontWeight: 600, letterSpacing: "-0.02em" },
|
||||
h3: { fontSize: "clamp(1.75rem, 3.5vw, 2.25rem)", lineHeight: 1.15, fontWeight: 600, letterSpacing: "-0.015em" },
|
||||
h4: { fontSize: "clamp(1.5rem, 3vw, 1.875rem)", lineHeight: 1.2, fontWeight: 600, letterSpacing: "-0.01em" },
|
||||
h5: { fontSize: "1.5rem", lineHeight: 1.25, fontWeight: 600 },
|
||||
h6: { fontSize: "1.25rem", lineHeight: 1.3, fontWeight: 600 },
|
||||
subtitle1: { fontSize: "1.125rem", lineHeight: 1.6 },
|
||||
subtitle2: { fontSize: "1rem", lineHeight: 1.6 },
|
||||
body1: { fontSize: "1.125rem", lineHeight: 1.6 },
|
||||
body2: { fontSize: "1rem", lineHeight: 1.6 },
|
||||
caption: { fontSize: "0.875rem", lineHeight: 1.5 },
|
||||
};
|
||||
|
||||
const defaultTag: Record<TypographyVariant, keyof JSX.IntrinsicElements> = {
|
||||
h1: "h1",
|
||||
h2: "h2",
|
||||
h3: "h3",
|
||||
h4: "h4",
|
||||
h5: "h5",
|
||||
h6: "h6",
|
||||
subtitle1: "p",
|
||||
subtitle2: "p",
|
||||
body1: "p",
|
||||
body2: "p",
|
||||
caption: "p",
|
||||
};
|
||||
|
||||
type Props<C extends keyof JSX.IntrinsicElements = "p"> = {
|
||||
variant: TypographyVariant;
|
||||
as?: C;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
} & Omit<React.ComponentPropsWithoutRef<C>, "className" | "children" | "style">;
|
||||
|
||||
export function Typography<C extends keyof JSX.IntrinsicElements = "p">({
|
||||
variant,
|
||||
as,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: Props<C>) {
|
||||
const Tag = (as ?? defaultTag[variant]) as keyof JSX.IntrinsicElements;
|
||||
const isHeading = variant.startsWith("h");
|
||||
const fontClass = isHeading ? "font-heading" : "font-body";
|
||||
|
||||
return React.createElement(
|
||||
Tag,
|
||||
{
|
||||
className: cn(fontClass, sizeClasses[variant], className),
|
||||
style: { ...sizeStyles[variant], ...style },
|
||||
...rest,
|
||||
},
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
export default Typography;
|
||||
427
editor/vendor/plugin-ai.css
vendored
Normal file
427
editor/vendor/plugin-ai.css
vendored
Normal file
@@ -0,0 +1,427 @@
|
||||
/* css-module:/Users/rami/Documents/apps/react-editor/packages/plugin-ai/src/panel/styles.module.css/#css-module-data */
|
||||
._AiPanel_1g0p6_1 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
._AiPanel-messages_1g0p6_8 {
|
||||
position: relative;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
._AiPanel-messages_1g0p6_8::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
._AiPanel-messagesContent_1g0p6_21 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
._AiPanel-scrollDown_1g0p6_30 {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--editor-border-default, #e0e0e2);
|
||||
background: var(--editor-color-white, #fff);
|
||||
color: var(--editor-text-primary, #111);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
transition: background-color var(--editor-motion-fast) var(--editor-ease);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
._AiPanel-scrollDown_1g0p6_30:hover {
|
||||
background: var(--editor-surface-hover, #f4f4f5);
|
||||
}
|
||||
}
|
||||
._AiPanel-empty_1g0p6_55 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--editor-text-tertiary, #888);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
._AiPanel-empty-icon_1g0p6_66 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--editor-text-secondary, #555);
|
||||
}
|
||||
._AiPanel-empty-text_1g0p6_73 {
|
||||
font-weight: 500;
|
||||
}
|
||||
._AiPanel-message_1g0p6_8 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
._AiPanel-message--user_1g0p6_85 {
|
||||
align-items: flex-end;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 {
|
||||
background: var(--editor-color-grey-12, #f4f4f5);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
max-width: 90%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
._AiPanel-message--user_1g0p6_85 ._AiPanel-message-bubble_1g0p6_89 {
|
||||
background: var(--editor-accent-soft);
|
||||
color: var(--editor-text-accent);
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 p,
|
||||
._AiPanel-message-bubble_1g0p6_89 ul,
|
||||
._AiPanel-message-bubble_1g0p6_89 ol,
|
||||
._AiPanel-message-bubble_1g0p6_89 li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 p + p,
|
||||
._AiPanel-message-bubble_1g0p6_89 p + ul,
|
||||
._AiPanel-message-bubble_1g0p6_89 p + ol,
|
||||
._AiPanel-message-bubble_1g0p6_89 p + pre,
|
||||
._AiPanel-message-bubble_1g0p6_89 p + blockquote,
|
||||
._AiPanel-message-bubble_1g0p6_89 p + table,
|
||||
._AiPanel-message-bubble_1g0p6_89 ul + p,
|
||||
._AiPanel-message-bubble_1g0p6_89 ol + p,
|
||||
._AiPanel-message-bubble_1g0p6_89 pre + p,
|
||||
._AiPanel-message-bubble_1g0p6_89 blockquote + p,
|
||||
._AiPanel-message-bubble_1g0p6_89 table + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 ul,
|
||||
._AiPanel-message-bubble_1g0p6_89 ol {
|
||||
padding-left: 18px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 li + li,
|
||||
._AiPanel-message-bubble_1g0p6_89 li > ul,
|
||||
._AiPanel-message-bubble_1g0p6_89 li > ol {
|
||||
margin-top: 0.375em;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 h1,
|
||||
._AiPanel-message-bubble_1g0p6_89 h2,
|
||||
._AiPanel-message-bubble_1g0p6_89 h3,
|
||||
._AiPanel-message-bubble_1g0p6_89 h4 {
|
||||
margin: 8px 0 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 h1 {
|
||||
font-size: 15px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 h3,
|
||||
._AiPanel-message-bubble_1g0p6_89 h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 blockquote {
|
||||
margin: 6px 0;
|
||||
padding: 0 8px;
|
||||
border-left: 2px solid var(--editor-border-default, #e0e0e2);
|
||||
color: var(--editor-text-secondary, #555);
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 pre {
|
||||
margin: 6px 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--editor-color-grey-12, #f4f4f5);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 code {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Consolas,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
background: var(--editor-color-grey-12, #f4f4f5);
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 table {
|
||||
margin: 6px 0;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 th,
|
||||
._AiPanel-message-bubble_1g0p6_89 td {
|
||||
border: 1px solid var(--editor-border-default, #e0e0e2);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 th {
|
||||
background: var(--editor-color-grey-12, #f4f4f5);
|
||||
font-weight: 600;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--editor-border-default, #e0e0e2);
|
||||
margin: 8px 0;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 a {
|
||||
color: var(--editor-text-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 em {
|
||||
font-style: italic;
|
||||
}
|
||||
._AiPanel-message-bubble_1g0p6_89 input[type=checkbox] {
|
||||
margin-right: 4px;
|
||||
}
|
||||
._AiPanel-toolCall_1g0p6_220 {
|
||||
--shiny-width: 80px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
font-size: 12px;
|
||||
color: var(--editor-text-tertiary, #888);
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--editor-text-primary, #111) 50%,
|
||||
transparent) no-repeat;
|
||||
background-size: var(--shiny-width) 100%;
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: _AiPanel-shine_1g0p6_1 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes _AiPanel-shine_1g0p6_1 {
|
||||
0%, 90%, 100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;
|
||||
}
|
||||
30%, 60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;
|
||||
}
|
||||
}
|
||||
._AiPanel-toolCall-done_1g0p6_249 {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
font-size: 12px;
|
||||
color: var(--editor-text-tertiary, #888);
|
||||
}
|
||||
._AiPanel-form_1g0p6_256 {
|
||||
padding: 10px;
|
||||
background: var(--editor-color-white, #fff);
|
||||
}
|
||||
._AiPanel-dots_1g0p6_261 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
._AiPanel-dots-dot_1g0p6_269 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--editor-text-accent);
|
||||
animation: _AiPanel-dots-pulse_1g0p6_1 1.2s infinite ease-in-out;
|
||||
}
|
||||
@keyframes _AiPanel-dots-pulse_1g0p6_1 {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
._AiPanel-inputGroup_1g0p6_288 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--editor-border-default, #e0e0e2);
|
||||
border-radius: var(--editor-radius-md, 8px);
|
||||
background: var(--editor-color-white, #fff);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--editor-motion-fast) var(--editor-ease), box-shadow var(--editor-motion-fast) var(--editor-ease);
|
||||
}
|
||||
._AiPanel-inputGroup_1g0p6_288:focus-within {
|
||||
border-color: var(--editor-primary, #111);
|
||||
box-shadow: var(--editor-ring);
|
||||
}
|
||||
._AiPanel-input_1g0p6_288 {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
padding: 10px 12px 6px;
|
||||
min-height: 56px;
|
||||
max-height: 200px;
|
||||
field-sizing: content;
|
||||
}
|
||||
._AiPanel-inputGroup-actions_1g0p6_320 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
._AiPanel-inputGroup-actions-left_1g0p6_328 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
._AiPanel-send_1g0p6_334 {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
background: var(--editor-primary);
|
||||
color: var(--editor-primary-foreground);
|
||||
border-radius: var(--editor-radius-md, 6px);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--editor-motion-fast) var(--editor-ease);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
._AiPanel-send_1g0p6_334:hover {
|
||||
background: var(--editor-primary-hover);
|
||||
}
|
||||
}
|
||||
._AiPanel-send_1g0p6_334:active {
|
||||
background: var(--editor-primary-hover);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
._AiPanel-send_1g0p6_334:disabled {
|
||||
background: var(--editor-border-subtle);
|
||||
color: var(--editor-text-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
._AiPanel-send_1g0p6_334 svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
._AiPanel-attach_1g0p6_372 {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--editor-text-secondary, #555);
|
||||
border-radius: var(--editor-radius-md, 6px);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--editor-motion-fast) var(--editor-ease), color var(--editor-motion-fast) var(--editor-ease);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
._AiPanel-attach_1g0p6_372:hover {
|
||||
background: var(--editor-surface-hover, #f4f4f5);
|
||||
color: var(--editor-text-primary, #111);
|
||||
}
|
||||
}
|
||||
._AiPanel-attach_1g0p6_372:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
._AiPanel-fileInput_1g0p6_401 {
|
||||
display: none;
|
||||
}
|
||||
._AiPanel-attachments_1g0p6_405 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
._AiPanel-attachment_1g0p6_405 {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--editor-color-grey-12, #f4f4f5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
._AiPanel-attachment-img_1g0p6_422 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
._AiPanel-attachment-remove_1g0p6_429 {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
._AiPanel-attachment-remove_1g0p6_429:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
._AiPanel-userImage_1g0p6_451 {
|
||||
display: block;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
background: var(--editor-color-grey-12, #f4f4f5);
|
||||
}
|
||||
._AiPanel-loader_1g0p6_460 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: _AiPanel-loader-spin_1g0p6_1 1s linear infinite;
|
||||
}
|
||||
@keyframes _AiPanel-loader-spin_1g0p6_1 {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
._AiPanel-error_1g0p6_471 {
|
||||
color: var(--editor-color-red-05, #d44);
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
Reference in New Issue
Block a user