commit 3a3ca1c72a4f40714b72a89d81c196d89ef0c6a7 Author: Rami Bitar Date: Sun May 3 20:12:12 2026 -0400 Initial commit diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..8226032 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,6 @@ +# Shopify (defaults to mock.shop if unset) +VITE_SHOPIFY_DOMAIN=mock.shop +VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN= + +# Anthropic — used by /api/chat. Required for the AI panel. +ANTHROPIC_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1010f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.next +.DS_Store +.env +.env.local +.env.*.local +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef38390 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# React Editor Demo (Shopify) + +Standalone Vite SPA wiring up the Shopify-aware React Editor via `` from `@reacteditor/core`. + +## What's here + +- **`index.html`** — Vite entrypoint. +- **`src/main.tsx`** — mounts `` into `#root`. +- **`src/App.tsx`** — wires `` from `@reacteditor/core` with the schema, plugins, and the Shopify provider. +- **`editor/`** — drop-in copy of the editor scaffold from `fe-shopify-client/app/editor`. Components, config, theme, Shopify client/queries/hooks/contexts, plugin-ai vendor css. +- **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`. +- **`api/chat.ts`** — `aiPlugin` endpoint exposed in dev via a Vite middleware. Talks to Claude via `@ai-sdk/anthropic` with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.). + +## Run + +```bash +cp .env.local.example .env.local # optional — defaults to mock.shop +yarn install +yarn dev # → http://localhost:3001 +``` + +## Shopify + +Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `VITE_SHOPIFY_DOMAIN` and `VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`. + +## AI + +`/api/chat` is served in dev by a Vite middleware that loads `api/chat.ts` via `ssrLoadModule`. Set `ANTHROPIC_API_KEY` in `.env.local`. Tools include the React Editor built-ins plus Shopify search/lookup helpers. For production deployment you'll need to host `api/chat.ts` behind your own Node server (the handler exports a standard `POST(req: Request): Promise`). diff --git a/api/chat.ts b/api/chat.ts new file mode 100644 index 0000000..c63bc58 --- /dev/null +++ b/api/chat.ts @@ -0,0 +1,227 @@ +import { + convertToModelMessages, + stepCountIs, + streamText, + tool, + type UIMessage, +} from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; +import { + reactEditorTools, + getEditorContext, +} from "@reacteditor/plugin-ai/server"; +import { shopifyFetch } from "@/editor/services/shopify/client"; +import { + GET_PRODUCTS_QUERY, + GET_PRODUCT_QUERY, +} from "@/editor/graphql/products"; +import { + GET_COLLECTIONS_QUERY, + GET_COLLECTION_PRODUCTS_QUERY, +} from "@/editor/graphql/collections"; + +type Body = { + messages: UIMessage[]; + editorContext?: Parameters[0]; + route?: string; +}; + +export async function POST(req: Request) { + const { messages, editorContext } = (await req.json()) as Body; + + const generateImage = tool({ + description: "Generate an image from a prompt and return its URL.", + inputSchema: z.object({ + prompt: z.string(), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + }), + execute: async ({ width = 768, height = 768 }) => ({ + url: `https://picsum.photos/${width}/${height}?random=${Math.floor( + Math.random() * 1_000_000, + )}`, + }), + }); + + const credentials = { + domain: + process.env.VITE_SHOPIFY_DOMAIN ?? + process.env.SHOPIFY_DOMAIN ?? + "mock.shop", + token: + process.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? + process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? + "", + }; + + const searchProducts = tool({ + description: + "Search the Shopify store for products. Returns up to `limit` products matching `query`.", + inputSchema: z.object({ + query: z.string().optional(), + limit: z.number().int().min(1).max(20).optional(), + }), + execute: async ({ query, limit = 8 }) => { + try { + const res = await shopifyFetch({ + query: GET_PRODUCTS_QUERY, + variables: { + first: limit, + query: query ?? null, + sortKey: query ? "RELEVANCE" : "BEST_SELLING", + reverse: false, + }, + credentials, + }); + const products = (res.data?.products?.edges ?? []).map((e: any) => { + const n = e.node; + return { + id: n.id, + handle: n.handle, + title: n.title, + description: n.description, + featuredImage: n.images?.edges?.[0]?.node ?? null, + priceRange: n.priceRange, + }; + }); + return { ok: true, products }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const getProductByHandle = tool({ + description: "Fetch a single product by its handle.", + inputSchema: z.object({ handle: z.string() }), + execute: async ({ handle }) => { + try { + const res = await shopifyFetch({ + query: GET_PRODUCT_QUERY, + variables: { handle }, + credentials, + }); + const p = res.data?.product ?? null; + if (!p) return { ok: false, error: "not_found" }; + return { + ok: true, + product: { + id: p.id, + handle: p.handle, + title: p.title, + description: p.description, + featuredImage: p.images?.edges?.[0]?.node ?? null, + priceRange: p.priceRange, + }, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const searchCollections = tool({ + description: "List up to `limit` collections from the Shopify store.", + inputSchema: z.object({ + limit: z.number().int().min(1).max(20).optional(), + }), + execute: async ({ limit = 8 }) => { + try { + const res = await shopifyFetch({ + query: GET_COLLECTIONS_QUERY, + variables: { first: limit }, + credentials, + }); + const collections = (res.data?.collections?.edges ?? []).map( + (e: any) => { + const n = e.node; + return { + id: n.id, + handle: n.handle, + title: n.title, + description: n.description, + image: n.image ?? null, + }; + }, + ); + return { ok: true, collections }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const getCollectionByHandle = tool({ + description: + "Fetch a single collection by its handle, including a small page of its products.", + inputSchema: z.object({ + handle: z.string(), + limit: z.number().int().min(1).max(20).optional(), + }), + execute: async ({ handle, limit = 8 }) => { + try { + const res = await shopifyFetch({ + query: GET_COLLECTION_PRODUCTS_QUERY, + variables: { + handle, + first: limit, + sortKey: "BEST_SELLING", + reverse: false, + }, + credentials, + }); + const c = res.data?.collection ?? null; + if (!c) return { ok: false, error: "not_found" }; + return { + ok: true, + collection: { + id: c.id, + handle: c.handle, + title: c.title, + description: c.description, + image: c.image ?? null, + products: (c.products?.edges ?? []).map((e: any) => ({ + id: e.node.id, + handle: e.node.handle, + title: e.node.title, + featuredImage: e.node.images?.edges?.[0]?.node ?? null, + priceRange: e.node.priceRange, + })), + }, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "fetch failed", + }; + } + }, + }); + + const result = streamText({ + model: anthropic("claude-sonnet-4-5"), + system: getEditorContext(editorContext), + messages: await convertToModelMessages(messages), + tools: { + ...reactEditorTools, + generateImage, + searchProducts, + getProductByHandle, + searchCollections, + getCollectionByHandle, + }, + stopWhen: stepCountIs(50), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/app.schema.json b/app.schema.json new file mode 100644 index 0000000..b402bd4 --- /dev/null +++ b/app.schema.json @@ -0,0 +1,277 @@ +{ + "/": { + "root": { + "props": { + "title": "Maison — Considered essentials", + "headerFont": "Playfair Display", + "bodyFont": "Inter", + "primaryColor": "#0a0a0a", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#0a0a0a", + "mutedColor": "#f5f5f5", + "roundedness": "md", + "shadowLevel": "sm", + "maxWidth": "xl" + } + }, + "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" } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "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", + "product": null, + "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." + } + } + ] + }, + "/products/:handle": { + "root": { + "props": { + "title": "Default product", + "headerFont": "Playfair Display", + "bodyFont": "Inter", + "primaryColor": "#0a0a0a", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#0a0a0a", + "mutedColor": "#f5f5f5", + "roundedness": "md", + "shadowLevel": "sm", + "maxWidth": "xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-product", + "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" + } + }, + { + "type": "product-details", + "props": { + "id": "product-details", + "product": null + } + }, + { + "type": "products-carousel", + "props": { + "id": "carousel-related", + "tagline": "You might also like", + "heading": "Related pieces", + "limit": 8, + "slidesPerView": "4", + "ctaLabel": "Shop all", + "ctaHref": "/collections" + } + }, + { + "type": "footer", + "props": { + "id": "footer-product", + "brand": "Maison", + "tagline": "Considered essentials, made in small batches.", + "columns": [ + { + "title": "Shop", + "links": [ + { "label": "All", "href": "/collections" }, + { "label": "New", "href": "/collections/new" } + ] + }, + { + "title": "Help", + "links": [ + { "label": "Shipping", "href": "/help/shipping" }, + { "label": "Returns", "href": "/help/returns" } + ] + } + ], + "social": [ + { "label": "Instagram", "href": "#" }, + { "label": "Pinterest", "href": "#" } + ], + "showNewsletter": "no", + "newsletterHeading": "Stay in touch", + "newsletterEndpoint": "", + "copyright": "© 2026 Maison. All rights reserved." + } + } + ] + } +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..6167d26 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/components/ui/accordion.tsx @@ -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 "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4dbb919 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; + +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +
+ +
+
+)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "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", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..a38fe5d --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,109 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..ba40cc1 --- /dev/null +++ b/components/ui/badge.tsx @@ -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 "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none 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 transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return