Files
react-editor-shopify/editor/components/navigation/navigation.editor.tsx
2026-05-02 09:18:15 -04:00

302 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ComponentConfig } from "@reacteditor/core";
import { Menu as MenuIcon, ShoppingBag, Search, User, X } from "lucide-react";
import { useState } from "react";
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";
showAccount: "yes" | "no";
showCart: "yes" | "no";
sticky: "yes" | "no";
tone: "default" | "muted" | "inverse";
};
function Navigation({
brand,
links,
showSearch,
showAccount,
showCart,
sticky,
tone,
}: 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",
};
return (
<>
<header
className={cn(
"w-full",
sticky === "yes" && "sticky top-0 z-40 backdrop-blur",
toneClass[tone],
)}
>
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-6 md:h-20">
<a
href="/"
className="font-semibold tracking-tight"
style={{ fontSize: "1.125rem", letterSpacing: "0.02em" }}
>
{brand}
</a>
<nav className="hidden items-center gap-8 md:flex">
{links.map((l) => (
<a
key={l.href + l.label}
href={l.href}
className="text-sm tracking-wide opacity-80 transition-opacity hover:opacity-100"
>
{l.label}
</a>
))}
</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>
)}
{showAccount === "yes" && (
<a
href="/account"
aria-label="Account"
className="hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-foreground/5 md:inline-flex"
>
<User size={18} strokeWidth={1.5} />
</a>
)}
{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>
{/* 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) => (
<a
key={l.href + l.label}
href={l.href}
className="rounded-md px-3 py-3 text-base hover:bg-muted"
>
{l.label}
</a>
))}
</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="flex items-center justify-between text-left">
<span>Cart</span>
<button
onClick={() => setCartOpen(false)}
className="rounded-full p-1 hover:bg-muted"
aria-label="Close cart"
>
<X size={16} />
</button>
</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>
</>
);
}
export const navigationEditor: ComponentConfig<NavigationProps> = {
label: "Navigation",
icon: <MenuIcon size={16} />,
category: "navigation",
defaultProps: {
brand: "Maison",
links: [
{ label: "Shop", href: "/collections" },
{ label: "Lookbook", href: "/lookbook" },
{ label: "Journal", href: "/journal" },
{ label: "About", href: "/about" },
],
showSearch: "yes",
showAccount: "yes",
showCart: "yes",
sticky: "yes",
tone: "default",
},
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" },
},
},
showSearch: {
label: "Search icon",
type: "radio",
options: [
{ label: "Show", value: "yes" },
{ label: "Hide", value: "no" },
],
},
showAccount: {
label: "Account 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} />,
};