Initial commit

This commit is contained in:
Rami Bitar
2026-08-08 14:21:24 -04:00
commit 58742d5d00
127 changed files with 18859 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Shopify Storefront
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN=
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
NEXT_PUBLIC_ENABLE_AI=0
# Store assistant (/api/chat) — https://openrouter.ai/keys
OPENROUTER_API_KEY=
# Optional; defaults to openai/gpt-5.6-luna-pro
# OPENROUTER_MODEL=
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
node_modules/*
.next
next-env.d.ts
.yarn/install-state.gz
.env*.local
tsconfig.tsbuildinfo
# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit
.hydrogen-gql-*.json
+1
View File
@@ -0,0 +1 @@
{}
@@ -0,0 +1,33 @@
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Activate your account — Shop' };
// Shopify's emailed activation link is /account/activate/{id}/{token}.
export default async function Page({
params,
}: {
params: Promise<{ id: string; token: string }>;
}) {
const { id, token } = await params;
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Activate your account"
description="Choose a password to finish setting up your account."
fields={[
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'new-password',
},
]}
submitLabel="Activate account"
endpoint="/api/account/activate"
extraPayload={{ id, activationToken: token }}
redirectTo="/account"
/>
</main>
);
}
+38
View File
@@ -0,0 +1,38 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Sign in — Shop' };
export default function Page() {
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Sign in"
fields={[
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'current-password',
},
]}
submitLabel="Sign in"
endpoint="/api/account/login"
redirectTo="/account"
footer={
<>
<span>
New here?{' '}
<AccountFormLink href="/account/register">
Create an account
</AccountFormLink>
</span>
<AccountFormLink href="/account/recover">
Forgot your password?
</AccountFormLink>
</>
}
/>
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from 'next/navigation';
import OrderHistory from '@/components/shopify/order-history';
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
export const metadata = { title: 'Order history — Shop' };
export default async function Page() {
const token = await getSessionToken();
if (!token) redirect('/account/login');
const customer = await getCustomer(token, 20);
// An expired or revoked token reads as signed out.
if (!customer) redirect('/account/login');
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
Order history
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{customer.displayName} · {customer.email}
</p>
<div className="mt-10">
<OrderHistory customer={customer} />
</div>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Reset password — Shop' };
export default function Page() {
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Reset password"
description="Enter your email and we'll send you a link to set a new password."
fields={[
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
]}
submitLabel="Send reset link"
endpoint="/api/account/recover"
successMessage="If that email has an account, a reset link is on its way."
footer={
<AccountFormLink href="/account/login">Back to sign in</AccountFormLink>
}
/>
</main>
);
}
+43
View File
@@ -0,0 +1,43 @@
import AccountForm, { AccountFormLink } from '@/components/shopify/account-form';
export const metadata = { title: 'Create account — Shop' };
export default function Page() {
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Create account"
fields={[
{
name: 'firstName',
label: 'First name',
autoComplete: 'given-name',
required: false,
},
{
name: 'lastName',
label: 'Last name',
autoComplete: 'family-name',
required: false,
},
{ name: 'email', label: 'Email', type: 'email', autoComplete: 'email' },
{
name: 'password',
label: 'Password',
type: 'password',
autoComplete: 'new-password',
},
]}
submitLabel="Create account"
endpoint="/api/account/register"
redirectTo="/account"
footer={
<span>
Already have an account?{' '}
<AccountFormLink href="/account/login">Sign in</AccountFormLink>
</span>
}
/>
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
import AccountForm from '@/components/shopify/account-form';
export const metadata = { title: 'Set a new password — Shop' };
// Shopify's emailed reset link is /account/reset/{id}/{token}.
export default async function Page({
params,
}: {
params: Promise<{ id: string; token: string }>;
}) {
const { id, token } = await params;
return (
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
<AccountForm
title="Set a new password"
fields={[
{
name: 'password',
label: 'New password',
type: 'password',
autoComplete: 'new-password',
},
]}
submitLabel="Save password"
endpoint="/api/account/reset"
extraPayload={{ id, resetToken: token }}
redirectTo="/account"
/>
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import {
activateAccount,
toCustomerGid,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { id, activationToken, password } = await req.json();
if (!id || !activationToken || !password) {
return Response.json(
{ error: 'This activation link is incomplete.' },
{ status: 400 }
);
}
const { token, errors } = await activateAccount(
toCustomerGid(id),
activationToken,
password
);
if (!token) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
+31
View File
@@ -0,0 +1,31 @@
import { login, customerErrorMessage } from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return Response.json(
{ error: 'Enter your email and password.' },
{ status: 400 }
);
}
const { token, errors } = await login(email, password);
if (!token) {
// Shopify distinguishes wrong-password from unknown-email; collapse both so
// the form can't be used to enumerate accounts.
return Response.json(
{
error: errors.length
? 'Incorrect email or password.'
: customerErrorMessage(errors),
},
{ status: 401 }
);
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
+10
View File
@@ -0,0 +1,10 @@
import { logout } from '@/services/shopify/customer';
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
export async function POST() {
const token = await getSessionToken();
if (token) await logout(token);
await clearSessionToken();
return Response.json({ ok: true });
}
+19
View File
@@ -0,0 +1,19 @@
import { getSessionToken } from '@/services/shopify/session';
import { getCustomer } from '@/services/shopify/customer';
// Minimal session probe for the header menu — never returns the access token.
export async function GET() {
const token = await getSessionToken();
if (!token) return Response.json({ customer: null });
const customer = await getCustomer(token, 0);
if (!customer) return Response.json({ customer: null });
return Response.json({
customer: {
displayName: customer.displayName,
email: customer.email,
firstName: customer.firstName,
},
});
}
+10
View File
@@ -0,0 +1,10 @@
import { recoverPassword } from '@/services/shopify/customer';
export async function POST(req: Request) {
const { email } = await req.json();
if (email) await recoverPassword(email);
// Always the same response, so the form can't reveal who has an account.
return Response.json({ ok: true });
}
+39
View File
@@ -0,0 +1,39 @@
import {
createCustomer,
login,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { email, password, firstName, lastName } = await req.json();
if (!email || !password) {
return Response.json(
{ error: 'Enter your email and password.' },
{ status: 400 }
);
}
const { errors } = await createCustomer({
email,
password,
firstName,
lastName,
});
if (errors.length) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
// Sign the new customer straight in. Accounts needing email confirmation
// won't return a token yet, which is not an error.
const { token } = await login(email, password);
if (token) {
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true, signedIn: true });
}
return Response.json({ ok: true, signedIn: false });
}
+27
View File
@@ -0,0 +1,27 @@
import {
resetPassword,
toCustomerGid,
customerErrorMessage,
} from '@/services/shopify/customer';
import { setSessionToken } from '@/services/shopify/session';
export async function POST(req: Request) {
const { id, resetToken, password } = await req.json();
if (!id || !resetToken || !password) {
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
}
const { token, errors } = await resetPassword(
toCustomerGid(id),
resetToken,
password
);
if (!token) {
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
}
await setSessionToken(token.accessToken, token.expiresAt);
return Response.json({ ok: true });
}
+207
View File
@@ -0,0 +1,207 @@
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { z } from 'zod';
import {
searchProducts,
getProduct,
getProductsPage,
getCollections,
getCollectionProductsPage,
isDefaultTitleOption,
isDefaultTitleSelection,
} from '@/services/shopify/catalog';
// Streaming needs the Node runtime here because the Storefront helpers run
// server-side on each tool call.
export const maxDuration = 30;
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
You help shoppers find products, compare options, and understand what the store
carries. You have tools that read live catalogue data — always use them rather
than guessing, and never invent products, prices, availability, or policies.
Guidelines:
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
something vague like "what do you have?", call listCollections or
searchCatalogue to ground your answer.
- Prices returned by the tools are in the store's currency; show them as given.
- Link products as /products/{handle} and collections as /collections/{handle}
so the shopper can click through.
- Keep replies short and conversational — a sentence or two plus a compact list.
Do not repeat the raw tool output; the interface already shows it.
- If a tool returns nothing, say so plainly and suggest a different search.
- You cannot place orders, change carts, process payments, or look up customer
or order data. Say so and point the shopper to the relevant page instead.`;
// Trim the Storefront payloads to what the model actually needs to answer.
const summariseProduct = (product: {
id: string;
title: string;
handle: string;
description?: string;
productType?: string;
tags?: string[];
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
variants?: {
edges: Array<{
node: {
title: string;
availableForSale: boolean;
selectedOptions?: Array<{ name: string; value: string }>;
};
}>;
};
images?: { edges: Array<{ node: { url: string } }> };
options?: Array<{ name: string; values: string[] }>;
}) => ({
title: product.title,
handle: product.handle,
url: `/products/${product.handle}`,
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
image: product.images?.edges[0]?.node.url ?? null,
description: product.description?.slice(0, 300) ?? null,
productType: product.productType || null,
tags: product.tags ?? [],
options: product.options
?.filter((option) => !isDefaultTitleOption(option))
.map((option) => ({
name: option.name,
values: option.values,
})),
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
});
export async function POST(req: Request) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return new Response(
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
const { messages } = await req.json();
const openrouter = createOpenRouter({ apiKey });
const result = streamText({
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
// renders it via the Reasoning component.
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
// Let the model call a tool, read the result, then answer.
stopWhen: stepCountIs(5),
tools: {
searchCatalogue: tool({
description:
'Search the store for products matching a term. Use for any question about what the store sells.',
inputSchema: z.object({
query: z
.string()
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
limit: z.number().int().min(1).max(10).default(5),
}),
execute: async ({ query, limit }) => {
const { products, totalCount } = await searchProducts({
query,
first: limit,
});
return {
totalCount,
products: products.map(summariseProduct),
};
},
}),
getProductDetails: tool({
description:
'Get full details for one product by its handle, including options, variants, and stock.',
inputSchema: z.object({
handle: z
.string()
.describe('The product handle, e.g. "flowguard-jacket".'),
}),
execute: async ({ handle }) => {
const product = await getProduct(handle);
if (!product) return { found: false, handle };
return {
found: true,
...summariseProduct(product),
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
title: node.title,
available: node.availableForSale,
price: `${node.price.amount} ${node.price.currencyCode}`,
options: node.selectedOptions?.filter(
(option) => !isDefaultTitleSelection(option)
),
})),
};
},
}),
listCollections: tool({
description:
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
inputSchema: z.object({
limit: z.number().int().min(1).max(25).default(10),
}),
execute: async ({ limit }) => {
const collections = await getCollections(limit);
return {
collections: collections.map((collection) => ({
title: collection.title,
handle: collection.handle,
url: `/collections/${collection.handle}`,
description: collection.description?.slice(0, 200) ?? null,
})),
};
},
}),
getCollectionProducts: tool({
description:
'List the products inside one collection, by collection handle.',
inputSchema: z.object({
handle: z.string().describe('The collection handle, e.g. "men".'),
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ handle, limit }) => {
const page = await getCollectionProductsPage(handle, { first: limit });
if (!page.collection) return { found: false, handle };
return {
found: true,
collection: page.collection.title,
url: `/collections/${handle}`,
products: page.products.map(summariseProduct),
};
},
}),
browseProducts: tool({
description:
'Browse the newest products when the shopper has no specific search term.',
inputSchema: z.object({
limit: z.number().int().min(1).max(20).default(8),
}),
execute: async ({ limit }) => {
const page = await getProductsPage({
first: limit,
sortKey: 'CREATED_AT',
reverse: true,
});
return { products: page.products.map(summariseProduct) };
},
}),
},
});
// sendReasoning forwards reasoning parts to the client; without it the
// stream carries text and tool calls only.
return result.toUIMessageStreamResponse({ sendReasoning: true });
}
+5
View File
@@ -0,0 +1,5 @@
import CollectionDetail from '@/components/shopify/collection-detail';
export default function Page() {
return <CollectionDetail />;
}
+5
View File
@@ -0,0 +1,5 @@
import Collections from '@/components/shopify/collections';
export default function Page() {
return <Collections title="Our Collections" />;
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import React from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
Something went wrong
</div>
<div className="border-l border-gray-300 h-6"></div>
<div className="text-sm text-gray-700 font-sans max-w-lg">
{error.message}
</div>
</div>
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme {
/* Refined neutral modern palette */
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%);
/* Card */
--color-card: hsl(0 0% 100%);
--color-card-foreground: hsl(240 10% 3.9%);
/* Popover */
--color-popover: hsl(0 0% 100%);
--color-popover-foreground: hsl(240 10% 3.9%);
/* Primary - Deep neutral slate for professional look */
--color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%);
/* Secondary */
--color-secondary: hsl(240 4.8% 95.9%);
--color-secondary-foreground: hsl(240 5.9% 10%);
/* Muted */
--color-muted: hsl(240 4.8% 95.9%);
--color-muted-foreground: hsl(240 3.8% 46.1%);
/* Accent - Subtle warm gray */
--color-accent: hsl(30 6.7% 95%);
--color-accent-foreground: hsl(240 5.9% 10%);
/* Destructive */
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(0 0% 98%);
/* Shop (accelerated checkout) purple */
--color-shop: #5a31f4;
/* Border */
--color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%);
--color-ring: hsl(240 5.9% 10%);
/* Radius - Modern larger corners */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
/* Typography — Geist Sans / Geist Mono */
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
--font-body: var(--font-geist-sans), system-ui, sans-serif;
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
}
/* Base styles for light, modern Shopify storefront */
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
components (e.g. Button's outline variant) rely on this base layer to pick up
the theme's border colour instead of the text colour. */
@layer base {
* {
border-color: var(--color-border);
}
}
body {
font-feature-settings:
'kern' 1,
'tnum' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Modern card design for products and collections */
.card-modern {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--color-border);
}
.card-modern:hover {
transform: translateY(-8px);
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1);
border-color: hsl(240 5.9% 85%);
}
/* Heading styles — regular weight, slightly tightened like the Geist reference */
h1,
h2,
.font-heading {
font-weight: 400;
letter-spacing: -0.02em;
}
/* Swipeable rows (mobile galleries) without a visible scrollbar */
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
.option-unavailable {
background-image: linear-gradient(
to top right,
transparent calc(50% - 0.5px),
currentColor calc(50% - 0.5px),
currentColor calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
}
/* Product description (HTML returned by the Storefront API) */
.product-description p {
margin-bottom: 1rem;
}
.product-description > :last-child {
margin-bottom: 0;
}
/* Shop policy body (HTML returned by the Storefront API) */
.policy-body p,
.policy-body ul,
.policy-body ol {
margin-bottom: 1.5rem;
}
.policy-body ul,
.policy-body ol {
padding-left: 1.25rem;
list-style: revert;
}
.policy-body li {
margin-bottom: 0.5rem;
}
.policy-body h2,
.policy-body h3,
.policy-body h4 {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: 1.25rem;
font-weight: 500;
}
.policy-body a {
text-decoration: underline;
text-underline-offset: 2px;
}
.policy-body strong {
font-weight: 500;
}
.policy-body > :last-child {
margin-bottom: 0;
}
/* Enhanced button styles */
button,
.btn-modern {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-modern:hover {
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
/* Hero section styles */
.hero-bg {
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
}
/* Product gallery improvements */
.product-gallery-main {
transition: box-shadow 0.3s ease;
}
.product-gallery-main:hover {
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
}
/* Clean price display */
.price-display {
font-feature-settings: 'tnum';
}
@theme inline {
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
--color-color-5: var(--color-5);
--color-color-4: var(--color-4);
--color-color-3: var(--color-3);
--color-color-2: var(--color-2);
--color-color-1: var(--color-1);
@keyframes rainbow {
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
}
}
:root {
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
.dark {
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import './globals.css';
import { Geist, Geist_Mono } from 'next/font/google';
import Header from '@/components/shopify/header';
import Footer from '@/components/shopify/footer';
import StoreAssistant from '@/components/shopify/store-assistant';
import { site } from '@/config/site';
const geist = Geist({
subsets: ['latin'],
variable: '--font-geist-sans',
});
const geistMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-geist-mono',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
{/* Header owns the cart drawer, search and account menu, so every
route gets them by rendering here rather than page by page. */}
<Header
storeName={site.storeName}
logoUrl={site.logoUrl}
links={site.navLinks}
/>
<div className="flex-1">{children}</div>
<Footer storeName={site.storeName} copyright={site.copyright} />
<StoreAssistant />
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import React from 'react';
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex items-center gap-6">
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
404
</div>
<div className="border-l border-gray-300 h-6"></div>
<div className="text-sm text-gray-700 font-sans max-w-lg">
This page could not be found.
</div>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import Products from '@/components/shopify/products';
export default function Page() {
return (
<Products
title="Shopify Hydrogen Storefront"
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
/>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { notFound } from 'next/navigation';
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
export function generateStaticParams() {
return POLICY_HANDLES.map((handle) => ({ handle }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const policy = await getShopPolicy(handle);
return {
title: policy ? `${policy.title} — Shop` : 'Policy — Shop',
};
}
export default async function PolicyPage({
params,
}: {
params: Promise<{ handle: string }>;
}) {
const { handle } = await params;
const policy = await getShopPolicy(handle);
if (!policy) {
notFound();
}
return (
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{policy.title}
</h1>
<div
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
dangerouslySetInnerHTML={{ __html: policy.body }}
/>
</div>
</main>
);
}
+11
View File
@@ -0,0 +1,11 @@
import ProductDetail from '@/components/shopify/product-detail';
import ProductRecommendations from '@/components/shopify/product-recommendations';
export default function Page() {
return (
<>
<ProductDetail addToCartLabel="Add to Cart" />
<ProductRecommendations title="You May Also Like" />
</>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Suspense } from 'react';
import SearchResults from '@/components/shopify/search-results';
export const metadata = {
title: 'Search — Shop',
};
export default function Page() {
return (
// useSearchParams needs a Suspense boundary to prerender this route.
<Suspense fallback={<div className="py-10" />}>
<SearchResults />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import Collections from '@/components/shopify/collections';
export default function Page() {
return <Collections title="Our Collections" />;
}
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}"
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
const About: React.FC = () => {
return (
<div className="text-center">
<h1 className="text-5xl font-bold text-black mb-8" style={{fontFamily: 'Space Grotesk, sans-serif'}}>
About Page
</h1>
</div>
);
};
export default About;
+426
View File
@@ -0,0 +1,426 @@
"use client";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import type { FileUIPart, SourceDocumentUIPart } from "ai";
import {
FileTextIcon,
GlobeIcon,
ImageIcon,
Music2Icon,
PaperclipIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo } from "react";
// ============================================================================
// Types
// ============================================================================
export type AttachmentData =
| (FileUIPart & { id: string })
| (SourceDocumentUIPart & { id: string });
export type AttachmentMediaCategory =
| "image"
| "video"
| "audio"
| "document"
| "source"
| "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
audio: Music2Icon,
document: FileTextIcon,
image: ImageIcon,
source: GlobeIcon,
unknown: PaperclipIcon,
video: VideoIcon,
};
// ============================================================================
// Utility Functions
// ============================================================================
export const getMediaCategory = (
data: AttachmentData
): AttachmentMediaCategory => {
if (data.type === "source-document") {
return "source";
}
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) {
return "image";
}
if (mediaType.startsWith("video/")) {
return "video";
}
if (mediaType.startsWith("audio/")) {
return "audio";
}
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
return "document";
}
return "unknown";
};
export const getAttachmentLabel = (data: AttachmentData): string => {
if (data.type === "source-document") {
return data.title || data.filename || "Source";
}
const category = getMediaCategory(data);
return data.filename || (category === "image" ? "Image" : "Attachment");
};
const renderAttachmentImage = (
url: string,
filename: string | undefined,
isGrid: boolean
) =>
isGrid ? (
<img
alt={filename || "Image"}
className="size-full object-cover"
height={96}
src={url}
width={96}
/>
) : (
<img
alt={filename || "Image"}
className="size-full rounded object-cover"
height={20}
src={url}
width={20}
/>
);
// ============================================================================
// Contexts
// ============================================================================
interface AttachmentsContextValue {
variant: AttachmentVariant;
}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue {
data: AttachmentData;
mediaCategory: AttachmentMediaCategory;
onRemove?: () => void;
variant: AttachmentVariant;
}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
// ============================================================================
// Hooks
// ============================================================================
export const useAttachmentsContext = () =>
useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => {
const ctx = useContext(AttachmentContext);
if (!ctx) {
throw new Error("Attachment components must be used within <Attachment>");
}
return ctx;
};
// ============================================================================
// Attachments - Container
// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
variant?: AttachmentVariant;
};
export const Attachments = ({
variant = "grid",
className,
children,
...props
}: AttachmentsProps) => {
const contextValue = useMemo(() => ({ variant }), [variant]);
return (
<AttachmentsContext.Provider value={contextValue}>
<div
className={cn(
"flex items-start",
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
variant === "grid" && "ml-auto w-fit",
className
)}
{...props}
>
{children}
</div>
</AttachmentsContext.Provider>
);
};
// ============================================================================
// Attachment - Item
// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: AttachmentData;
onRemove?: () => void;
};
export const Attachment = ({
data,
onRemove,
className,
children,
...props
}: AttachmentProps) => {
const { variant } = useAttachmentsContext();
const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>(
() => ({ data, mediaCategory, onRemove, variant }),
[data, mediaCategory, onRemove, variant]
);
return (
<AttachmentContext.Provider value={contextValue}>
<div
className={cn(
"group relative",
variant === "grid" && "size-24 overflow-hidden rounded-lg",
variant === "inline" && [
"flex h-8 cursor-pointer select-none items-center gap-1.5",
"rounded-md border border-border px-1.5",
"font-medium text-sm transition-all",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
],
variant === "list" && [
"flex w-full items-center gap-3 rounded-lg border p-3",
"hover:bg-accent/50",
],
className
)}
{...props}
>
{children}
</div>
</AttachmentContext.Provider>
);
};
// ============================================================================
// AttachmentPreview - Media preview
// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
fallbackIcon?: ReactNode;
};
export const AttachmentPreview = ({
fallbackIcon,
className,
...props
}: AttachmentPreviewProps) => {
const { data, mediaCategory, variant } = useAttachmentContext();
const iconSize = variant === "inline" ? "size-3" : "size-4";
const renderIcon = (Icon: typeof ImageIcon) => (
<Icon className={cn(iconSize, "text-muted-foreground")} />
);
const renderContent = () => {
if (mediaCategory === "image" && data.type === "file" && data.url) {
return renderAttachmentImage(data.url, data.filename, variant === "grid");
}
if (mediaCategory === "video" && data.type === "file" && data.url) {
return <video className="size-full object-cover" muted src={data.url} />;
}
const Icon = mediaCategoryIcons[mediaCategory];
return fallbackIcon ?? renderIcon(Icon);
};
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden",
variant === "grid" && "size-full bg-muted",
variant === "inline" && "size-5 rounded bg-background",
variant === "list" && "size-12 rounded bg-muted",
className
)}
{...props}
>
{renderContent()}
</div>
);
};
// ============================================================================
// AttachmentInfo - Name and type display
// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
showMediaType?: boolean;
};
export const AttachmentInfo = ({
showMediaType = false,
className,
...props
}: AttachmentInfoProps) => {
const { data, variant } = useAttachmentContext();
const label = getAttachmentLabel(data);
if (variant === "grid") {
return null;
}
return (
<div className={cn("min-w-0 flex-1", className)} {...props}>
<span className="block truncate">{label}</span>
{showMediaType && data.mediaType && (
<span className="block truncate text-muted-foreground text-xs">
{data.mediaType}
</span>
)}
</div>
);
};
// ============================================================================
// AttachmentRemove - Remove button
// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
label?: string;
};
export const AttachmentRemove = ({
label = "Remove",
className,
children,
...props
}: AttachmentRemoveProps) => {
const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
},
[onRemove]
);
if (!onRemove) {
return null;
}
return (
<Button
aria-label={label}
className={cn(
variant === "grid" && [
"absolute top-2 right-2 size-6 rounded-full p-0",
"bg-background/80 backdrop-blur-sm",
"opacity-0 transition-opacity group-hover:opacity-100",
"hover:bg-background",
"[&>svg]:size-3",
],
variant === "inline" && [
"size-5 rounded p-0",
"opacity-0 transition-opacity group-hover:opacity-100",
"[&>svg]:size-2.5",
],
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
className
)}
onClick={handleClick}
type="button"
variant="ghost"
{...props}
>
{children ?? <XIcon />}
<span className="sr-only">{label}</span>
</Button>
);
};
// ============================================================================
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const AttachmentHoverCardTrigger = (
props: AttachmentHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type AttachmentHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const AttachmentHoverCardContent = ({
align = "start",
className,
...props
}: AttachmentHoverCardContentProps) => (
<HoverCardContent
align={align}
className={cn("w-auto p-2", className)}
{...props}
/>
);
// ============================================================================
// AttachmentEmpty - Empty state
// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({
className,
children,
...props
}: AttachmentEmptyProps) => (
<div
className={cn(
"flex items-center justify-center p-4 text-muted-foreground text-sm",
className
)}
{...props}
>
{children ?? "No attachments"}
</div>
);
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (
message: UIMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Nodes } from "hast";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import type { Components } from "hast-util-to-jsx-runtime";
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
import { Fragment, memo, useCallback, useMemo, useState } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import remend from "remend";
import { unified } from "unified";
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
// is off by default) and rehype-sanitize is the second line of defence, mainly
// for its href protocol allow-list — that is what stops `javascript:` URLs.
// Relative hrefs carry no protocol, so storefront links pass through untouched.
const schema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// GFM task lists render as disabled checkboxes. The default schema allows
// `type` and `disabled` but not `checked`, so every box would read unticked.
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
},
};
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize, schema);
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
// default instead emits a `streamdown:incomplete-link` placeholder href, which
// the sanitizer would strip anyway.
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
// remend only ever runs on text that is still arriving. On finished text it can
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
// bullet. Prices in italics are ordinary storefront copy, so settled messages
// render verbatim and only the in-flight one gets repaired.
const repair = (markdown: string, isStreaming: boolean) =>
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
export interface LinkSafetyConfig {
enabled: boolean;
/** Return true for links that may open without a confirmation step. */
onLinkCheck?: (url: string) => boolean;
}
type AnchorProps = ComponentProps<"a"> & {
linkSafety?: LinkSafetyConfig;
onUntrusted: (url: string) => void;
};
const LINK_CLASS =
"font-medium underline underline-offset-4 hover:text-foreground";
// Route-relative hrefs are the storefront links the assistant emits. Deciding
// this from the string alone keeps the server and client passes identical;
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
const isRouteHref = (href: string) => /^[/#?]/.test(href);
const MarkdownLink = ({
href,
linkSafety,
onUntrusted,
...props
}: AnchorProps) => {
// Runs on click rather than on render so it can read `window.location`,
// which is unavailable during the server pass.
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
if (linkSafety.onLinkCheck(href)) return;
event.preventDefault();
onUntrusted(href);
},
[href, linkSafety, onUntrusted]
);
// In-app destinations navigate client-side in the same tab; sending a shopper
// to a product page in a new tab would strand the conversation behind it.
if (href && isRouteHref(href)) {
return <Link className={LINK_CLASS} href={href} {...props} />;
}
return (
<a
className={LINK_CLASS}
href={href}
onClick={handleClick}
rel="noreferrer"
target="_blank"
{...props}
/>
);
};
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
const buildComponents = (
linkSafety: LinkSafetyConfig | undefined,
onUntrusted: (url: string) => void
): Partial<Components> => ({
a: (props: ComponentProps<"a">) => (
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
),
blockquote: (props: ComponentProps<"blockquote">) => (
<blockquote
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
{...props}
/>
),
code: ({ className, ...props }: ComponentProps<"code">) => {
// Only fenced blocks carry a language class, and those already sit inside a
// <pre>, so they must not get the inline pill treatment.
const isBlock =
typeof className === "string" && className.includes("language-");
return (
<code
className={cn(
isBlock
? "font-mono text-xs"
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
className
)}
{...props}
/>
);
},
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
h1: (props: ComponentProps<"h1">) => (
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h2: (props: ComponentProps<"h2">) => (
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
),
h3: (props: ComponentProps<"h3">) => (
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h4: (props: ComponentProps<"h4">) => (
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h5: (props: ComponentProps<"h5">) => (
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
h6: (props: ComponentProps<"h6">) => (
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
),
hr: (props: ComponentProps<"hr">) => (
<hr className="my-4 border-border" {...props} />
),
// GFM task-list boxes are display only; readOnly silences React's warning
// about a `checked` input with no change handler.
input: (props: ComponentProps<"input">) => (
<input className="mr-1.5 align-middle" readOnly {...props} />
),
img: ({ alt, ...props }: ComponentProps<"img">) => (
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
),
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
ol: (props: ComponentProps<"ol">) => (
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
),
p: (props: ComponentProps<"p">) => (
<p className="my-2 leading-relaxed" {...props} />
),
pre: (props: ComponentProps<"pre">) => (
<pre
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
{...props}
/>
),
strong: (props: ComponentProps<"strong">) => (
<strong className="font-semibold" {...props} />
),
table: (props: ComponentProps<"table">) => (
<div className="my-3 overflow-x-auto">
<table className="w-full border-collapse text-left text-xs" {...props} />
</div>
),
td: (props: ComponentProps<"td">) => (
<td className="border border-border px-2 py-1" {...props} />
),
th: (props: ComponentProps<"th">) => (
<th
className="border border-border bg-muted px-2 py-1 font-medium"
{...props}
/>
),
ul: (props: ComponentProps<"ul">) => (
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
),
});
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
children: string;
/** True while tokens are still arriving; enables incomplete-syntax repair. */
isAnimating?: boolean;
linkSafety?: LinkSafetyConfig;
};
export const Markdown = memo(
({
children,
className,
isAnimating,
linkSafety,
...props
}: MarkdownProps) => {
const [pending, setPending] = useState<string | null>(null);
const handleUntrusted = useCallback((url: string) => setPending(url), []);
const content = useMemo(() => {
// While streaming, close syntax the model has not finished emitting so a
// partial `**bold` renders as bold rather than as literal asterisks.
const source = repair(children ?? "", isAnimating === true);
const tree = processor.runSync(processor.parse(source)) as Nodes;
return toJsxRuntime(tree, {
components: buildComponents(linkSafety, handleUntrusted),
Fragment,
jsx,
jsxs,
});
}, [children, isAnimating, linkSafety, handleUntrusted]);
return (
<div
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
>
{content}
<Dialog
onOpenChange={(open) => !open && setPending(null)}
open={pending !== null}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Leave this site?</DialogTitle>
<DialogDescription>
This link points somewhere outside the store. Continue only if
you trust it.
</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
{pending}
</p>
<DialogFooter>
<Button onClick={() => setPending(null)} variant="outline">
Cancel
</Button>
<Button
onClick={() => {
if (pending) {
window.open(pending, "_blank", "noopener,noreferrer");
}
setPending(null);
}}
>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
},
(prev, next) =>
prev.children === next.children && prev.isAnimating === next.isAnimating
);
Markdown.displayName = "Markdown";
+339
View File
@@ -0,0 +1,339 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Markdown } from "./markdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Markdown>;
// Markdown already memoises on children/isAnimating.
export const MessageResponse = Markdown;
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Markdown } from "./markdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => {
const { isStreaming } = useReasoning();
return (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Markdown isAnimating={isStreaming}>{children}</Markdown>
</CollapsibleContent>
);
}
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import Link from 'next/link';
interface LogoProps {
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
src?: string | null;
/** Wordmark text, and the image's alt text. */
storeName?: string;
/** Where the logo links to. Pass null to render it unwrapped. */
href?: string | null;
/** Sizing for the image — height only, so the aspect ratio is preserved. */
imageClassName?: string;
/** Sizing and weight for the wordmark fallback. */
textClassName?: string;
}
const Logo: React.FC<LogoProps> = ({
src,
storeName = 'Shop',
href = '/',
imageClassName = 'h-6',
textClassName = 'text-xl',
}) => {
const mark = src ? (
<img
src={src}
alt={storeName}
className={`w-auto object-contain ${imageClassName}`}
/>
) : (
<span
className={`font-medium tracking-tight text-foreground ${textClassName}`}
>
{storeName}
</span>
);
if (href === null) {
return <span className="flex items-center">{mark}</span>;
}
return (
<Link href={href} className="flex items-center">
{mark}
</Link>
);
};
export default Logo;
+40
View File
@@ -0,0 +1,40 @@
import React, { memo } from 'react';
import { cn } from '@/lib/utils';
interface AuroraTextProps {
children: React.ReactNode;
className?: string;
colors?: string[];
speed?: number;
}
export const AuroraText = memo(
({
children,
className,
colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'],
speed = 1,
}: AuroraTextProps) => {
const animationDuration = `${10 / speed}s`;
const gradientStyle = {
backgroundImage: `linear-gradient(90deg, ${colors.join(', ')}, ${colors[0]})`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
animation: `aurora-flow ${animationDuration} ease-in-out infinite`,
} as React.CSSProperties;
return (
<span className={cn('relative inline-block', className)}>
<span className="sr-only">{children}</span>
<span className="relative" style={gradientStyle} aria-hidden="true">
{children}
</span>
</span>
);
}
);
AuroraText.displayName = 'AuroraText';
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import { useRef, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
interface BlurFadeProps {
children: React.ReactNode;
className?: string;
duration?: number;
delay?: number;
inView?: boolean;
}
export function BlurFade({
children,
className,
duration = 0.4,
delay = 0,
inView = false,
}: BlurFadeProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(!inView);
useEffect(() => {
if (!inView) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(entry.target);
}
},
{ threshold: 0.1, rootMargin: '-50px' }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [inView]);
return (
<div
ref={ref}
className={cn(
isVisible ? 'opacity-100 blur-none' : 'opacity-0 blur-sm',
className
)}
style={{
animation: isVisible
? `blur-fade ${duration}s ease-out ${delay}s forwards`
: 'none',
}}
>
{children}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
export interface AccountFormField {
name: string;
label: string;
type?: 'text' | 'email' | 'password';
autoComplete?: string;
required?: boolean;
}
interface AccountFormProps {
title: string;
description?: string;
fields: AccountFormField[];
submitLabel: string;
endpoint: string;
/** Merged into the request body alongside the field values. */
extraPayload?: Record<string, string>;
/** Where to go on success; omit to show `successMessage` instead. */
redirectTo?: string;
successMessage?: string;
footer?: React.ReactNode;
}
const AccountForm: React.FC<AccountFormProps> = ({
title,
description,
fields,
submitLabel,
endpoint,
extraPayload,
redirectTo,
successMessage,
footer,
}) => {
const router = useRouter();
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (submitting) return;
try {
setSubmitting(true);
setError(null);
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...values, ...extraPayload }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error ?? 'Something went wrong. Please try again.');
return;
}
if (redirectTo) {
// refresh() so server components re-read the new session cookie.
router.push(redirectTo);
router.refresh();
} else {
setDone(true);
}
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<div className="mx-auto w-full max-w-sm">
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
{description && (
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
)}
{done && successMessage ? (
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
) : (
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
{fields.map((field) => (
<label key={field.name} className="flex flex-col gap-1.5">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
<input
type={field.type ?? 'text'}
name={field.name}
required={field.required ?? true}
autoComplete={field.autoComplete}
value={values[field.name] ?? ''}
onChange={(event) =>
setValues((prev) => ({
...prev,
[field.name]: event.target.value,
}))
}
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
/>
</label>
))}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={submitting} className="h-11">
{submitting && <Loader size={16} />}
{submitLabel}
</Button>
</form>
)}
{footer && (
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
{footer}
</div>
)}
</div>
);
};
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
href,
children,
}) => (
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
{children}
</Link>
);
export default AccountForm;
+112
View File
@@ -0,0 +1,112 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { RiUserLine } from '@remixicon/react';
interface SessionCustomer {
displayName: string;
email: string;
firstName?: string | null;
}
const AccountMenu: React.FC = () => {
const router = useRouter();
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
const [open, setOpen] = useState(false);
// The token lives in an httpOnly cookie, so the signed-in state has to come
// from the server rather than being read directly.
useEffect(() => {
let cancelled = false;
fetch('/api/account/me')
.then((response) => response.json())
.then((data) => {
if (!cancelled) setCustomer(data.customer ?? null);
})
.catch(() => {
if (!cancelled) setCustomer(null);
});
return () => {
cancelled = true;
};
}, []);
const handleSignOut = async () => {
setOpen(false);
await fetch('/api/account/logout', { method: 'POST' });
setCustomer(null);
router.push('/');
router.refresh();
};
// Signed out: straight to the sign-in page, no menu.
if (!customer) {
return (
<Button
asChild
variant="ghost"
size="icon"
aria-label="Sign in"
className="rounded-full"
>
<Link href="/account/login">
<RiUserLine className="size-5" />
</Link>
</Button>
);
}
return (
<div className="relative">
<Button
onClick={() => setOpen((prev) => !prev)}
variant="ghost"
size="icon"
aria-label="Account menu"
aria-expanded={open}
className="rounded-full"
>
<RiUserLine className="size-5" />
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
<div className="px-4 py-2">
<p className="truncate text-sm font-medium text-foreground">
{customer.firstName || customer.displayName}
</p>
<p className="truncate text-xs text-muted-foreground">
{customer.email}
</p>
</div>
<div className="my-1 h-px bg-border" />
<Link
href="/account"
onClick={() => setOpen(false)}
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
>
Order history
</Link>
<button
onClick={handleSignOut}
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
Sign out
</button>
</div>
</>
)}
</div>
);
};
export default AccountMenu;
+348
View File
@@ -0,0 +1,348 @@
'use client';
import React, { useState } from 'react';
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import {
RiCloseLine,
RiImageLine,
RiSubtractLine,
RiAddLine,
} from '@remixicon/react';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
const CartDrawer: React.FC = () => {
const isOpen = useCartStore((s) => s.isOpen);
const closeCart = useCartStore((s) => s.closeCart);
const loading = useCartStore((s) => s.loading);
const cart = useCartStore((s) => s.cart);
const removeItem = useCartStore((s) => s.removeItem);
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
const [discountCode, setDiscountCode] = useState('');
const [discountError, setDiscountError] = useState<string | null>(null);
const [applyingDiscount, setApplyingDiscount] = useState(false);
// The store's `loading` flag is global, so track the specific line being
// changed to keep the other rows interactive.
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
if (pendingLineId) return;
try {
setPendingLineId(lineId);
await action();
} catch (err) {
console.error('Cart line update failed:', err);
} finally {
setPendingLineId(null);
}
};
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;
const appliedDiscounts =
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
const handleCheckout = () => {
if (checkoutUrl) {
redirectToCheckout(checkoutUrl);
}
};
const handleApplyDiscount = async (event: React.FormEvent) => {
event.preventDefault();
const code = discountCode.trim();
if (!code || applyingDiscount) return;
try {
setApplyingDiscount(true);
setDiscountError(null);
const updatedCart = await applyDiscountCode(code);
// Shopify accepts unknown codes silently, flagging them as inapplicable.
const accepted = updatedCart.discountCodes?.some(
(discount) =>
discount.applicable &&
discount.code.toLowerCase() === code.toLowerCase()
);
if (accepted) {
setDiscountCode('');
} else {
setDiscountError('That code is not valid for this cart.');
}
} catch {
setDiscountError('Could not apply that code. Please try again.');
} finally {
setApplyingDiscount(false);
}
};
const getItemImage = (item: (typeof items)[0]) => {
return item.merchandise.image?.url;
};
const getSelectedOptions = (item: (typeof items)[0]) => {
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
return (item.merchandise.selectedOptions ?? []).filter(
(option) => !isDefaultTitleSelection(option)
);
};
return (
<Sheet
open={isOpen}
onOpenChange={(open) => !open && closeCart()}
side="right"
>
<AnimatePresence>
{isOpen && (
<SheetContent className="w-full max-w-md" showCloseButton={false}>
{/* Header */}
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
<div className="flex items-center justify-between w-full">
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
Cart
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{itemCount}
</span>
</SheetTitle>
<Button
onClick={closeCart}
variant="ghost"
size="icon"
aria-label="Close cart"
className="rounded-full"
>
<RiCloseLine className="size-5" />
</Button>
</div>
</SheetHeader>
{/* Cart Items */}
<SheetBody className="px-5">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader size={20} />
</div>
) : items.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Your cart is empty</EmptyTitle>
<EmptyDescription>
Add some products to get started!
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={closeCart} className="w-full">
Continue Shopping
</Button>
</EmptyContent>
</Empty>
) : (
<div className="space-y-5">
{items.map((item) => {
const image = getItemImage(item);
const selectedOptions = getSelectedOptions(item);
const isPending = pendingLineId === item.id;
return (
<div key={item.id} className="flex items-start gap-x-3">
{/* Product Image */}
<div className="w-16 h-16 bg-zinc-100 overflow-hidden 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-zinc-400">
<RiImageLine size={20} />
</div>
)}
</div>
{/* Product Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-x-3">
<h4 className="text-sm font-medium text-foreground line-clamp-2">
{item.merchandise.product.title}
</h4>
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
$
{parseFloat(
item.cost?.totalAmount?.amount ??
item.merchandise.price.amount
).toFixed(2)}
</span>
</div>
{selectedOptions.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
{selectedOptions
.map((option) => option.value)
.join(' / ')}
</div>
)}
{/* Quantity Controls */}
<div className="flex items-center gap-x-2 mt-2">
<div className="inline-flex items-center rounded-full bg-secondary">
<Button
onClick={() =>
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity - 1
)
)
}
disabled={item.quantity <= 1 || isPending}
variant="ghost"
size="icon-sm"
aria-label="Decrease quantity"
className="size-7 rounded-full"
>
<RiSubtractLine size={14} />
</Button>
<span className="min-w-8 px-1 text-sm tabular-nums text-center">
{isPending ? (
<Loader size={12} className="mx-auto" />
) : (
item.quantity
)}
</span>
<Button
onClick={() =>
runLineAction(item.id, () =>
updateItemQuantity(
item.id,
item.quantity + 1
)
)
}
disabled={isPending}
variant="ghost"
size="icon-sm"
aria-label="Increase quantity"
className="size-7 rounded-full"
>
<RiAddLine size={14} />
</Button>
</div>
<Button
onClick={() =>
runLineAction(item.id, () => removeItem(item.id))
}
disabled={isPending}
variant="link"
aria-label={`Remove ${item.merchandise.product.title}`}
className="ml-auto h-7 self-center px-0 text-xs font-normal leading-none text-muted-foreground underline decoration-dashed underline-offset-2 hover:text-foreground hover:no-underline"
>
remove
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</SheetBody>
{/* Footer — discount, total, checkout */}
{items.length > 0 && (
<div className="px-5 py-5 space-y-4">
{/* Discount Code */}
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
<input
type="text"
value={discountCode}
onChange={(event) => {
setDiscountCode(event.target.value);
setDiscountError(null);
}}
placeholder="Discount code"
aria-label="Discount code"
className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors"
/>
<Button
type="submit"
disabled={!discountCode.trim() || applyingDiscount}
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
>
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
</Button>
</form>
{discountError && (
<p className="text-xs text-destructive">{discountError}</p>
)}
{appliedDiscounts.length > 0 && (
<p className="text-xs text-muted-foreground">
Applied: {appliedDiscounts.map((d) => d.code).join(', ')}
</p>
)}
{/* Estimated total */}
<div>
<div className="flex items-baseline justify-between">
<span className="text-base text-foreground">
Estimated total
</span>
<span className="font-mono tabular-nums tracking-tight text-lg text-foreground">
${totalAmount.toFixed(2)}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Taxes and shipping calculated at checkout.
</p>
</div>
<Button
onClick={handleCheckout}
disabled={!checkoutUrl || pendingLineId !== null}
className="h-12 w-full"
>
Go to Checkout
</Button>
<Button
onClick={closeCart}
variant="ghost"
className="w-full font-normal text-muted-foreground hover:text-foreground"
>
Continue Shopping
</Button>
</div>
)}
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default CartDrawer;
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
import Link from 'next/link';
interface CollectionImage {
url: string;
altText?: string | null;
}
interface Collection {
id: string;
title: string;
handle: string;
description?: string;
image?: CollectionImage | null;
}
interface CollectionCardProps {
collection: Collection;
}
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
return (
<Link
href={`/collections/${collection.handle}`}
className="group block h-full"
>
{/* Collection Image */}
<div className="relative aspect-square overflow-hidden">
{collection.image ? (
<img
src={collection.image.url}
alt={collection.image.altText || collection.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-folder-line text-8xl"></i>
</div>
)}
</div>
{/* Collection Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{collection.title}
</h3>
</div>
</Link>
);
};
export default CollectionCard;
+208
View File
@@ -0,0 +1,208 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters, { type ProductFilterFacet } from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
getCollectionProductsPage,
type CollectionSortKey,
} from '@/hooks/use-shopify-collections';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
const GRID_CLASSES =
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
interface SortOption {
label: string;
sortKey: CollectionSortKey;
reverse: boolean;
}
const SORT_OPTIONS: SortOption[] = [
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
];
const CollectionDetail: React.FC = () => {
const params = useParams();
const handle = params?.handle as string;
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
const [title, setTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
// Fall back to the handle until the collection's real title arrives.
const formattedTitle = handle
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
: 'Collection';
useEffect(() => {
if (!handle) return;
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
if (!page.collection) {
setError('Collection not found');
return;
}
setTitle(page.collection.title);
setProducts(page.products);
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
// Keep the facet list stable while a selection is active, so options
// don't disappear out from under the panel.
if (activeFilters.length === 0) setFilters(page.filters);
} catch (err) {
if (cancelled) return;
console.error('Error fetching collection products:', err);
setError(err instanceof Error ? err.message : 'Failed to load collection');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [handle, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const page = await getCollectionProductsPage(handle, {
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
});
setCursor(page.endCursor);
setHasNextPage(page.hasNextPage);
} catch (err) {
console.error('Failed to load more products:', err);
} finally {
setLoadingMore(false);
}
};
return (
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
{title || formattedTitle}
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={products.length > 0 ? products.length : null}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
<div className="mt-8">
{loading ? (
<div className={GRID_CLASSES}>
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div>
))}
</div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
{activeFilters.length > 0
? 'No products matched your filters.'
: "This collection doesn't have any products yet."}
</p>
) : (
<>
<div className={GRID_CLASSES}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div>
</div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
export default CollectionDetail;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React from 'react';
import { useCollections } from '@/hooks/use-shopify-collections';
import CollectionCard from './collection-card';
import { Button } from '@/components/ui/button';
interface CollectionsProps {
title?: string;
subtitle?: string;
}
const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
title,
subtitle,
}) => (
<>
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
{subtitle && (
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
)}
</>
);
const GRID_CLASSES = 'grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12';
const Collections: React.FC<CollectionsProps> = ({
title = 'Our Collections',
subtitle = 'Discover our carefully crafted worlds',
}) => {
const { collections, loading, error, refetch } = useCollections(12);
if (loading) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<SectionHeader title={title} subtitle={subtitle} />
<div className={GRID_CLASSES}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4">
<div className="h-4 bg-zinc-200 w-3/5"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<SectionHeader title={title} subtitle={subtitle} />
<p className="text-sm text-muted-foreground mb-6">{error}</p>
<Button onClick={refetch} variant="outline">
Retry
</Button>
</div>
</div>
);
}
if (collections.length === 0) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<SectionHeader title={title} subtitle={subtitle} />
<p className="text-sm text-muted-foreground">
Collections will appear here once added to your Shopify store.
</p>
</div>
</div>
);
}
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8">
<SectionHeader title={title} subtitle={subtitle} />
<div className={GRID_CLASSES}>
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} />
))}
</div>
</div>
</div>
);
};
export default Collections;
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import {
RiInstagramLine,
RiTiktokLine,
RiFacebookFill,
} from '@remixicon/react';
import Logo from '@/components/logo';
export interface FooterLink {
label: string;
url: string;
}
interface FooterProps {
storeName?: string;
logoUrl?: string;
copyright?: string;
links?: FooterLink[];
instagramUrl?: string;
tiktokUrl?: string;
facebookUrl?: string;
}
const Footer: React.FC<FooterProps> = ({
storeName = 'Shop',
logoUrl,
copyright,
links = [
{ label: 'Terms of Service', url: '/policies/terms-of-service' },
{ label: 'Privacy Policy', url: '/policies/privacy-policy' },
{ label: 'Refund Policy', url: '/policies/refund-policy' },
{ label: 'Shipping Policy', url: '/policies/shipping-policy' },
{ label: 'Subscription Policy', url: '/policies/subscription-policy' },
],
instagramUrl = '#',
tiktokUrl = '#',
facebookUrl = '#',
}) => {
const socials = [
{ label: 'Instagram', url: instagramUrl, Icon: RiInstagramLine },
{ label: 'TikTok', url: tiktokUrl, Icon: RiTiktokLine },
{ label: 'Facebook', url: facebookUrl, Icon: RiFacebookFill },
];
return (
<footer className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-10">
{/* Left-aligned: the bottom-right corner belongs to the floating
assistant launcher. Links and socials sit on separate rows so the
icons aren't crammed onto the end of the link list. */}
<div className="flex flex-col items-center gap-y-5 sm:items-start">
<Logo
src={logoUrl}
storeName={storeName}
imageClassName="h-5"
textClassName="text-base"
/>
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
{links.map((link) => (
<a
key={link.label}
href={link.url}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:gap-x-5">
<span className="flex items-center gap-x-4">
{socials.map(({ label, url, Icon }) => (
<a
key={label}
href={url}
aria-label={label}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Icon size={18} />
</a>
))}
</span>
<p className="text-sm text-muted-foreground leading-5">
{copyright || `© ${storeName}. All rights reserved.`}
</p>
</div>
</div>
</div>
</footer>
);
};
export default Footer;
+153
View File
@@ -0,0 +1,153 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useCartStore } from '@/hooks/use-shopify-cart';
import CartDrawer from '@/components/shopify/cart-drawer';
import SearchDialog from '@/components/shopify/search-dialog';
import AccountMenu from '@/components/shopify/account-menu';
import ShopMenu from '@/components/shopify/shop-menu';
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import Logo from '@/components/logo';
const CartIcon: React.FC = () => {
const toggleCart = useCartStore((s) => s.toggleCart);
const cart = useCartStore((s) => s.cart);
const itemCount =
cart?.lines?.edges?.reduce((sum, { node }) => sum + node.quantity, 0) ?? 0;
return (
<Button
onClick={toggleCart}
variant="ghost"
size="icon"
aria-label={`Open bag (${itemCount})`}
className="relative rounded-full"
>
<RiShoppingBagLine className="size-5" />
{itemCount > 0 && (
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
{itemCount > 99 ? '99+' : itemCount}
</span>
)}
</Button>
);
};
export interface NavLink {
label: string;
url: string;
}
interface HeaderProps {
storeName?: string;
logoUrl?: string;
links?: NavLink[];
/** Thin bar above the nav. Pass null to hide it. */
announcement?: string | null;
announcementUrl?: string;
}
const Header: React.FC<HeaderProps> = ({
storeName = 'Shop',
logoUrl,
links = [
{ label: 'Shop', url: '/' },
{ label: 'Collections', url: '/collections' },
],
announcement = 'Free shipping on orders over $100',
announcementUrl,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
return (
<>
{/* Sits above the sticky nav, so it scrolls away on its own. */}
{announcement && (
<div className="bg-muted text-foreground">
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
{announcementUrl ? (
<Link
href={announcementUrl}
className="underline-offset-2 hover:underline"
>
{announcement}
</Link>
) : (
<span>{announcement}</span>
)}
</div>
</div>
)}
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-screen-2xl mx-auto px-8">
<div className="flex justify-between items-center h-14">
{/* Logo */}
<Logo src={logoUrl} storeName={storeName} />
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-x-8 text-sm">
<ShopMenu />
{links.map((link, index) => (
<Link
key={index}
href={link.url}
className="text-foreground hover:text-muted-foreground transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Actions */}
<div className="flex items-center">
<SearchDialog />
<AccountMenu />
<CartIcon />
{/* Mobile hamburger */}
<Button
onClick={() => setMenuOpen(!menuOpen)}
variant="ghost"
size="icon"
className="md:hidden"
aria-label="Toggle menu"
>
{menuOpen ? (
<RiCloseLine className="size-5" />
) : (
<RiMenu3Line className="size-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile Menu */}
{menuOpen && (
<div className="md:hidden bg-background">
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
{links.map((link, index) => (
<Link
key={index}
href={link.url}
onClick={() => setMenuOpen(false)}
className="text-foreground hover:text-muted-foreground transition-colors"
>
{link.label}
</Link>
))}
</div>
</div>
)}
<CartDrawer />
</nav>
</>
);
};
export default Header;
+166
View File
@@ -0,0 +1,166 @@
'use client';
import React, { useState } from 'react';
import { RiImageLine } from '@remixicon/react';
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
interface OrderHistoryProps {
customer: Customer;
}
const formatMoney = (amount: string, currencyCode: string) => {
const value = parseFloat(amount);
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(value);
} catch {
return `$${value.toFixed(2)}`;
}
};
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
if (!label) return null;
return (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
{label.replace(/_/g, ' ').toLowerCase()}
</span>
);
};
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
const orders: CustomerOrder[] =
customer.orders?.edges.map((edge) => edge.node) ?? [];
// The Storefront API has no standalone order-by-id query for customers, so
// the detail is rendered from the order already loaded in this list.
const [selectedId, setSelectedId] = useState<string | null>(
orders[0]?.id ?? null
);
const selected = orders.find((order) => order.id === selectedId) ?? null;
if (orders.length === 0) {
return (
<p className="text-sm text-muted-foreground">
You haven&apos;t placed any orders yet.
</p>
);
}
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
{/* List */}
<div className="lg:col-span-2">
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
<ul className="flex flex-col">
{orders.map((order) => {
const isSelected = order.id === selectedId;
return (
<li key={order.id}>
<button
onClick={() => setSelectedId(order.id)}
aria-current={isSelected}
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
}`}
>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">
Order #{order.orderNumber}
</span>
<span className="block text-xs text-muted-foreground">
{formatDate(order.processedAt)}
</span>
</span>
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatMoney(
order.currentTotalPrice.amount,
order.currentTotalPrice.currencyCode
)}
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* Detail — same screen, no navigation */}
<div className="lg:col-span-3">
{selected && (
<>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 className="text-lg text-foreground">
Order #{selected.orderNumber}
</h2>
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
{formatMoney(
selected.currentTotalPrice.amount,
selected.currentTotalPrice.currencyCode
)}
</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
Placed {formatDate(selected.processedAt)}
</span>
<StatusPill label={selected.financialStatus} />
<StatusPill label={selected.fulfillmentStatus} />
</div>
<ul className="mt-6 flex flex-col gap-4">
{selected.lineItems.edges.map(({ node }, index) => (
<li key={index} className="flex items-start gap-x-3">
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
{node.variant?.image ? (
<img
src={node.variant.image.url}
alt={node.variant.image.altText || node.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-5" />
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{node.title}
</p>
<p className="text-xs text-muted-foreground">
Qty {node.quantity}
</p>
</div>
</li>
))}
</ul>
{selected.statusUrl && (
<a
href={selected.statusUrl}
target="_blank"
rel="noreferrer"
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
View order status
</a>
)}
</>
)}
</div>
</div>
);
};
export default OrderHistory;
+116
View File
@@ -0,0 +1,116 @@
import React from 'react';
import Link from 'next/link';
import { truncate } from '@/lib/utils';
interface ProductImage {
url: string;
altText?: string | null;
}
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 ProductCardProps {
product: Product;
}
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
const firstImage = product.images.edges[0]?.node;
const price = product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount =
compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const firstVariant = product.variants.edges[0]?.node;
const isAvailable = firstVariant?.availableForSale || false;
const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`;
};
return (
<Link
href={`/products/${product.handle}`}
className="group block h-full"
>
{/* Product Image */}
<div className="relative aspect-square overflow-hidden">
{firstImage ? (
<img
src={firstImage.url}
alt={firstImage.altText || product.title}
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-8xl"></i>
</div>
)}
{hasDiscount && compareAtPrice && (
<span className="absolute top-3 left-3 text-[11px] font-mono tracking-widest text-rose-600">
SALE
</span>
)}
{!isAvailable && (
<span className="absolute top-3 right-3 text-[11px] font-mono tracking-widest text-muted-foreground">
SOLD OUT
</span>
)}
</div>
{/* Product Info */}
<div className="flex flex-col flex-1 py-2.5">
<h3 className="text-sm font-medium text-foreground line-clamp-1">
{truncate(product.title, 65)}
</h3>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-mono tabular-nums tracking-tight text-sm text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
</div>
</Link>
);
};
export default ProductCard;
+3
View File
@@ -0,0 +1,3 @@
import ProductDetail from './product-detail/index';
export default ProductDetail;
+230
View File
@@ -0,0 +1,230 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useProduct, type Product } from '@/hooks/use-shopify-products';
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
import ProductDetailGallery from './product-detail-gallery';
import ProductDetailInfo from './product-detail-info';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from '@/components/ui/empty';
interface ProductVariant {
id: string;
title: string;
price: {
amount: string;
currencyCode: string;
};
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
image?: {
url: string;
altText?: string | null;
} | null;
}
export type { Product };
interface ProductDetailProps {
handle?: string;
addToCartLabel?: string;
}
const ProductDetail: React.FC<ProductDetailProps> = ({
handle: handleProp,
addToCartLabel = 'Add to Cart',
}) => {
const params = useParams();
const handle = handleProp || (params?.handle as string);
const { addItem, openCart, checkoutUrl } = 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 [addingToCart, setAddingToCart] = useState(false);
const [buyingNow, setBuyingNow] = 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]);
// A value is available if some in-stock variant carries it alongside the
// other currently-selected options. Options the shopper hasn't chosen yet
// act as wildcards, so nothing is struck through before a full selection.
const isOptionValueAvailable = (optionName: string, value: string) => {
const variants = product?.variants.edges ?? [];
if (variants.length === 0) return true;
return variants.some(({ node }) => {
if (!node.availableForSale) return false;
return node.selectedOptions.every((option) => {
if (option.name === optionName) return option.value === value;
const selected = selectedOptions[option.name];
return selected === undefined || selected === option.value;
});
});
};
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);
}
};
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);
}
};
// Adds the item, then sends the shopper straight to the Shopify checkout
// (where Shop Pay is offered) rather than opening the cart drawer.
const handleBuyNow = async () => {
if (!selectedVariant || !product) return;
try {
setBuyingNow(true);
const updatedCart = await addItem(selectedVariant.id, quantity);
const url = updatedCart?.checkoutUrl ?? checkoutUrl;
if (url) {
redirectToCheckout(url);
} else {
openCart();
}
} catch (err) {
console.error('Failed to start checkout:', err);
} finally {
setBuyingNow(false);
}
};
if (loading) {
return (
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
{/* Image Gallery Skeleton */}
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="aspect-square bg-zinc-100 animate-pulse"
></div>
))}
</div>
{/* Product Info Skeleton */}
<div className="lg:col-span-2 animate-pulse">
<div className="h-8 bg-zinc-100 w-2/3"></div>
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
<div className="h-8 bg-zinc-100 w-32 mt-8"></div>
<div className="h-10 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-8"></div>
<div className="h-12 bg-zinc-100 mt-3"></div>
</div>
</div>
</div>
);
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8">
<Empty className="min-h-[400px]">
<EmptyHeader>
<EmptyTitle>Product Not Found</EmptyTitle>
<EmptyDescription>
{error || 'The requested product could not be found.'}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => window.history.back()} variant="outline">
Go Back
</Button>
</EmptyContent>
</Empty>
</div>
);
}
return (
<div className="bg-background">
<div className="max-w-screen-2xl mx-auto px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-start">
<div className="lg:col-span-3">
<ProductDetailGallery
images={product.images.edges.map((edge) => edge.node)}
/>
</div>
<div className="lg:col-span-2 lg:sticky lg:top-20">
<ProductDetailInfo
product={product}
selectedVariant={selectedVariant}
selectedOptions={selectedOptions}
quantity={quantity}
setQuantity={setQuantity}
handleAddToCart={handleAddToCart}
handleBuyNow={handleBuyNow}
onOptionChange={handleOptionChange}
isOptionValueAvailable={isOptionValueAvailable}
loading={addingToCart}
buyingNow={buyingNow}
addToCartLabel={addToCartLabel}
/>
</div>
</div>
</div>
</div>
);
};
export default ProductDetail;
@@ -0,0 +1,198 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RiCloseLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
interface ProductImage {
url: string;
altText?: string | null;
}
interface ProductDetailGalleryProps {
images: ProductImage[];
}
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
images,
}) => {
const [zoomedIndex, setZoomedIndex] = useState<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollerRef = useRef<HTMLDivElement>(null);
const isZoomed = zoomedIndex !== null;
const close = useCallback(() => setZoomedIndex(null), []);
// The slide whose left edge sits closest to the scroller's left edge is the
// one in view. Measuring rects keeps this correct whatever the gap or width.
const handleScroll = useCallback(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const scrollerLeft = scroller.getBoundingClientRect().left;
let nearest = 0;
let smallestOffset = Infinity;
Array.from(scroller.children).forEach((child, index) => {
const offset = Math.abs(child.getBoundingClientRect().left - scrollerLeft);
if (offset < smallestOffset) {
smallestOffset = offset;
nearest = index;
}
});
setActiveIndex(nearest);
}, []);
// Touch already scrolls natively; this adds click-and-drag for pointers that
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'touch') return;
const scroller = scrollerRef.current;
if (!scroller) return;
drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft };
scroller.style.scrollSnapType = 'none';
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
event.preventDefault();
scroller.scrollLeft =
drag.current.startScroll - (event.clientX - drag.current.startX);
};
const endDrag = () => {
const scroller = scrollerRef.current;
if (!drag.current || !scroller) return;
drag.current = null;
// Restoring snap lets the browser settle on the nearest slide.
scroller.style.scrollSnapType = '';
};
const scrollToIndex = (index: number) => {
const scroller = scrollerRef.current;
const slide = scroller?.children[index];
if (!scroller || !slide) return;
const offset =
slide.getBoundingClientRect().left - scroller.getBoundingClientRect().left;
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
};
// Close on Escape, and keep the page behind the overlay from scrolling.
useEffect(() => {
if (!isZoomed) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
document.addEventListener('keydown', onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [isZoomed, close]);
if (images.length === 0) {
return (
<div className="aspect-square bg-zinc-50 flex items-center justify-center text-zinc-300">
<i className="ri-image-line text-[120px]"></i>
</div>
);
}
const isSingle = images.length === 1;
const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
return (
<>
{/* Swipeable carousel on mobile, grid from sm up */}
<div
ref={scrollerRef}
onScroll={handleScroll}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerLeave={endDrag}
onPointerCancel={endDrag}
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar touch-pan-x sm:grid sm:grid-cols-2 sm:touch-auto sm:overflow-visible"
>
{images.map((image, index) => (
<button
key={index}
onClick={() => setZoomedIndex(index)}
aria-label={`Zoom ${image.altText || 'product image'}`}
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
isSingle ? 'sm:col-span-2' : ''
}`}
>
<img
src={image.url}
alt={image.altText || 'Product image'}
draggable={false}
className="w-full h-full object-cover select-none"
/>
</button>
))}
</div>
{/* Carousel pagination — the grid needs no dots, so mobile only */}
{!isSingle && (
<div className="flex justify-center gap-2 pt-4 sm:hidden">
{images.map((_, index) => (
<button
key={index}
onClick={() => scrollToIndex(index)}
aria-label={`Go to image ${index + 1}`}
aria-current={index === activeIndex}
className={`h-1.5 rounded-full transition-all ${
index === activeIndex
? 'w-5 bg-foreground'
: 'w-1.5 bg-border hover:bg-foreground/40'
}`}
/>
))}
</div>
)}
{zoomedImage && (
<div
role="dialog"
aria-modal="true"
aria-label={zoomedImage.altText || 'Product image'}
onClick={close}
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
>
<img
src={zoomedImage.url}
alt={zoomedImage.altText || 'Product image'}
onClick={(event) => event.stopPropagation()}
className="max-h-full max-w-full object-contain"
/>
<Button
onClick={close}
variant="ghost"
size="icon-lg"
aria-label="Close"
className="absolute top-4 right-4 rounded-full bg-background shadow-sm hover:bg-secondary"
>
<RiCloseLine size={20} />
</Button>
</div>
)}
</>
);
};
export default ProductDetailGallery;
@@ -0,0 +1,282 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
import ShopPayButton from '@/components/shopify/shop-pay-button';
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
import { isDefaultTitleOption } from '@/services/shopify/catalog';
interface ProductPrice {
amount: string;
currencyCode: string;
}
interface ProductVariant {
id: string;
title: string;
price: ProductPrice;
availableForSale: boolean;
selectedOptions: Array<{
name: string;
value: string;
}>;
}
interface Product {
id: string;
title: string;
description?: string;
descriptionHtml?: string;
handle: string;
priceRange: {
minVariantPrice: ProductPrice;
};
compareAtPriceRange?: {
minVariantPrice: ProductPrice;
};
options: ProductOption[];
}
export interface ProductFeature {
icon: string;
label: string;
}
interface ProductDetailInfoProps {
product: Product;
selectedVariant: ProductVariant | null;
selectedOptions: Record<string, string>;
quantity: number;
setQuantity: (quantity: number) => void;
handleAddToCart: () => void;
handleBuyNow?: () => void;
onOptionChange: (optionName: string, value: string) => void;
/** Whether an option value still has an in-stock variant behind it. */
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
loading?: boolean;
buyingNow?: boolean;
addToCartLabel?: string;
}
// A swatch comes from the option value's own swatch (colour or image) or the
// colour its name implies (see config/swatches) — never a variant photo.
const swatchStyle = (
value: ProductOptionValue
): { background?: string; image?: string } => {
if (value.swatch?.color) return { background: value.swatch.color };
const swatchImage = value.swatch?.image?.previewImage?.url;
if (swatchImage) return { image: swatchImage };
const namedColor = swatchColorForName(value.name);
if (namedColor) return { background: namedColor };
return {};
};
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
product,
selectedVariant,
selectedOptions,
quantity,
setQuantity,
handleAddToCart,
handleBuyNow,
onOptionChange,
isOptionValueAvailable,
loading = false,
buyingNow = false,
addToCartLabel = 'Add to Cart',
}) => {
const formatPrice = (amount: string) => {
return `$${parseFloat(amount).toFixed(2)}`;
};
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
const hasDiscount =
compareAtPrice &&
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
const isAvailable = selectedVariant?.availableForSale ?? false;
const isSwatchOption = (option: ProductOption) =>
isSwatchOptionName(option.name);
// Some products return Size before Color; show the swatches first either way.
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
const orderedOptions = [...(product.options ?? [])]
.filter((option) => !isDefaultTitleOption(option))
.sort((a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a)));
// `optionValues` carries the swatch data; fall back to plain `values`.
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
option.optionValues?.length
? option.optionValues
: option.values.map((value) => ({ id: value, name: value }));
return (
<div>
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
{product.title}
</h1>
<div className="flex items-baseline gap-x-3 mt-1">
<span className="font-mono tabular-nums tracking-tight text-base text-foreground">
{formatPrice(price.amount)}
</span>
{hasDiscount && compareAtPrice && (
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
{formatPrice(compareAtPrice.amount)}
</span>
)}
</div>
{/* Product Options — colour swatches lead, whatever order the API returns */}
{orderedOptions.map((option) => {
const isSwatch = isSwatchOption(option);
const selected = selectedOptions[option.name];
return (
<div key={option.id} className="mt-8">
<div className="text-sm text-muted-foreground mb-2">
{option.name}
{isSwatch && selected && (
<span className="text-foreground font-medium">: {selected}</span>
)}
</div>
<div className="flex flex-wrap gap-2">
{optionValuesFor(option).map((value) => {
const isSelected = selected === value.name;
const isSoldOut =
isOptionValueAvailable?.(option.name, value.name) === false;
if (isSwatch) {
const { background, image } = swatchStyle(value);
return (
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="ghost"
size="icon-sm"
aria-label={value.name}
aria-pressed={isSelected}
title={
isSoldOut ? `${value.name} — out of stock` : value.name
}
data-available={!isSoldOut}
className={`rounded-full bg-cover bg-center hover:bg-transparent ${
isSelected
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
style={{
// With no colour and no image the circle would be fully
// transparent, leaving just a hairline ring that
// antialiases unevenly and reads as a speckled border.
// A neutral fill makes the initial-letter fallback look
// deliberate. Set inline so it beats the ghost variant's
// hover background.
backgroundColor:
background ??
(image ? undefined : 'var(--color-muted)'),
backgroundImage: image ? `url(${image})` : undefined,
}}
>
{!background && !image && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.name.at(0)}
</span>
)}
</Button>
);
}
return (
<Button
key={value.id}
onClick={() => onOptionChange(option.name, value.name)}
variant="outline"
aria-pressed={isSelected}
title={isSoldOut ? `${value.name} — out of stock` : undefined}
className={`min-w-14 px-5 font-normal shadow-none ${
isSelected
? 'border-foreground text-foreground'
: 'text-muted-foreground hover:border-foreground hover:text-foreground'
} ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`}
>
{value.name}
</Button>
);
})}
</div>
</div>
);
})}
{/* Quantity + Add to Cart */}
<div className="mt-8 flex items-stretch gap-3">
<div className="flex items-center rounded-md border border-border h-11">
<Button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
variant="ghost"
size="icon"
aria-label="Decrease quantity"
className="h-full rounded-none rounded-l-md"
>
<RiSubtractLine size={16} />
</Button>
<span className="w-8 text-center text-sm tabular-nums">
{quantity}
</span>
<Button
onClick={() => setQuantity(quantity + 1)}
variant="ghost"
size="icon"
aria-label="Increase quantity"
className="h-full rounded-none rounded-r-md"
>
<RiAddLine size={16} />
</Button>
</div>
<Button
onClick={handleAddToCart}
disabled={!isAvailable || loading}
className="flex-1 h-11"
>
{loading && <Loader size={16} />}
{isAvailable ? addToCartLabel : 'Out of Stock'}
</Button>
</div>
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
<ShopPayButton
className="mt-3"
variants={
selectedVariant ? [{ id: selectedVariant.id, quantity }] : []
}
disabled={!isAvailable || buyingNow}
loading={buyingNow}
onFallbackClick={handleBuyNow}
/>
{/* Description */}
{(product.descriptionHtml || product.description) && (
<div className="mt-10 text-sm leading-6 text-foreground product-description">
{product.descriptionHtml ? (
<div
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
) : (
<p>{product.description}</p>
)}
</div>
)}
</div>
);
};
export default ProductDetailInfo;
+258
View File
@@ -0,0 +1,258 @@
'use client';
import React, { useState } from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetBody,
AnimatePresence,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
import { swatchColorForName } from '@/config/swatches';
// Shape of a Storefront facet — identical for `search.productFilters` and
// `collection.products.filters`, so both pages share this panel.
export interface ProductFilterValue {
id: string;
label: string;
count: number;
/** JSON string accepted back as a `ProductFilter` input. */
input: string;
}
export interface ProductFilterFacet {
id: string;
label: string;
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
values: ProductFilterValue[];
}
interface ProductFiltersProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: ProductFilterFacet[];
activeFilters: string[];
onActiveFiltersChange: (filters: string[]) => void;
}
// Colour facets render as swatches; everything else as a labelled list.
const isColorFilter = (filter: ProductFilterFacet) =>
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
const ProductFilters: React.FC<ProductFiltersProps> = ({
open,
onOpenChange,
filters,
activeFilters,
onActiveFiltersChange,
}) => {
const [priceMin, setPriceMin] = useState('');
const [priceMax, setPriceMax] = useState('');
const listFilters = filters.filter((filter) => filter.type === 'LIST');
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
const activeSet = new Set(activeFilters);
// Price is rebuilt from the inputs rather than toggled, so track it apart.
const activePriceInput = activeFilters.find((input) =>
input.includes('"price"')
);
const toggleValue = (value: ProductFilterValue) => {
onActiveFiltersChange(
activeSet.has(value.input)
? activeFilters.filter((input) => input !== value.input)
: [...activeFilters, value.input]
);
};
const applyPrice = () => {
const min = parseFloat(priceMin);
const max = parseFloat(priceMax);
const withoutPrice = activeFilters.filter(
(input) => !input.includes('"price"')
);
if (Number.isNaN(min) && Number.isNaN(max)) {
onActiveFiltersChange(withoutPrice);
return;
}
const price: { min?: number; max?: number } = {};
if (!Number.isNaN(min)) price.min = min;
if (!Number.isNaN(max)) price.max = max;
onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]);
};
const clearAll = () => {
setPriceMin('');
setPriceMax('');
onActiveFiltersChange([]);
};
// Labels for the chips at the top of the panel.
const activeChips = filters
.flatMap((filter) => filter.values)
.filter((value) => activeSet.has(value.input));
return (
<Sheet open={open} onOpenChange={onOpenChange} side="left">
<AnimatePresence>
{open && (
<SheetContent className="w-full max-w-sm" showCloseButton={false}>
<SheetHeader className="min-h-0 border-b-0 px-5 py-4">
<div className="flex w-full items-center justify-between">
<SheetTitle className="text-lg font-medium">Filters</SheetTitle>
<Button
onClick={() => onOpenChange(false)}
variant="ghost"
size="icon-sm"
aria-label="Close filters"
>
<RiCloseLine size={20} />
</Button>
</div>
</SheetHeader>
<SheetBody className="px-5">
{/* Active selections */}
{(activeChips.length > 0 || activePriceInput) && (
<div className="mb-6 flex flex-wrap items-center gap-2">
{activeChips.map((value) => (
<button
key={value.id}
onClick={() => toggleValue(value)}
className="flex items-center gap-x-1 rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
>
{value.label}
<RiCloseLine size={12} />
</button>
))}
<Button
onClick={clearAll}
variant="link"
className="h-auto px-0 text-xs font-normal text-muted-foreground"
>
Clear all
</Button>
</div>
)}
{filters.length === 0 && (
<p className="text-sm text-muted-foreground">
No filters available for these results.
</p>
)}
{/* Price */}
{priceFilter && (
<div className="mb-8">
<h3 className="mb-3 text-base text-foreground">Price</h3>
<div className="flex items-center gap-x-2">
<input
type="number"
inputMode="decimal"
value={priceMin}
onChange={(event) => setPriceMin(event.target.value)}
placeholder="$ From"
aria-label="Minimum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<span className="text-muted-foreground"></span>
<input
type="number"
inputMode="decimal"
value={priceMax}
onChange={(event) => setPriceMax(event.target.value)}
placeholder="$ To"
aria-label="Maximum price"
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
/>
<Button
onClick={applyPrice}
size="icon"
aria-label="Apply price range"
className="shrink-0"
>
<RiCheckLine size={16} />
</Button>
</div>
</div>
)}
{/* Facets */}
{listFilters.map((filter) => (
<div key={filter.id} className="mb-8">
<h3 className="mb-3 text-base text-foreground">
{filter.label}
</h3>
{isColorFilter(filter) ? (
<div className="flex flex-wrap gap-2">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
const color = swatchColorForName(value.label);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
title={`${value.label} (${value.count})`}
aria-label={value.label}
aria-pressed={isActive}
className={`h-8 w-8 rounded-full transition-shadow ${
isActive
? 'ring-2 ring-foreground ring-offset-2'
: 'ring-1 ring-border hover:ring-foreground/40'
}`}
style={{
// A colourless swatch would otherwise be a fully
// transparent circle behind a hairline ring,
// which antialiases into a speckled border.
backgroundColor: color ?? 'var(--color-muted)',
}}
>
{!color && (
<span className="text-[10px] font-medium uppercase text-muted-foreground">
{value.label.at(0)}
</span>
)}
</button>
);
})}
</div>
) : (
<div className="flex flex-col">
{filter.values.map((value) => {
const isActive = activeSet.has(value.input);
return (
<button
key={value.id}
onClick={() => toggleValue(value)}
aria-pressed={isActive}
className="flex items-center justify-between py-1.5 text-left text-sm text-foreground hover:text-muted-foreground"
>
<span>
{value.label} ({value.count})
</span>
{isActive && <RiCheckLine size={16} />}
</button>
);
})}
</div>
)}
</div>
))}
</SheetBody>
</SheetContent>
)}
</AnimatePresence>
</Sheet>
);
};
export default ProductFilters;
@@ -0,0 +1,73 @@
'use client';
import React from 'react';
import { useParams } from 'next/navigation';
import {
useProduct,
useProductRecommendations,
} from '@/hooks/use-shopify-products';
import ProductCard from './product-card';
interface ProductRecommendationsProps {
productId?: string;
title?: string;
limit?: number;
}
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
productId: productIdProp,
title = 'You May Also Like',
limit = 4,
}) => {
const params = useParams();
const handle = params?.handle as string | undefined;
const { product } = useProduct(productIdProp ? null : (handle ?? null));
const resolvedProductId = productIdProp || product?.id || '';
const { recommendations, loading, error } = useProductRecommendations(
resolvedProductId || null
);
if (!loading && (!recommendations || recommendations.length === 0)) {
return null;
}
return (
<section className="bg-background py-16">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-2xl md:text-3xl font-normal text-foreground mb-8">
{title}
</h2>
{error ? (
<p className="text-sm text-muted-foreground">
Recommendations could not be loaded
</p>
) : (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12">
{loading
? Array.from({ length: limit }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
</div>
</div>
))
: recommendations
.slice(0, limit)
.map((recommendedProduct) => (
<ProductCard
key={recommendedProduct.id}
product={recommendedProduct}
/>
))}
</div>
)}
</div>
</section>
);
};
export default ProductRecommendations;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
RiEqualizerLine,
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react';
export interface ToolbarSortOption {
label: string;
}
interface ProductToolbarProps {
totalCount?: number | null;
onOpenFilters: () => void;
sortOptions: ToolbarSortOption[];
sortIndex: number;
onSortChange: (index: number) => void;
activeFilterCount?: number;
}
// Filters trigger on the left, item count and sort menu on the right — shared
// by the search results and collection pages.
const ProductToolbar: React.FC<ProductToolbarProps> = ({
totalCount,
onOpenFilters,
sortOptions,
sortIndex,
onSortChange,
activeFilterCount = 0,
}) => {
const [sortOpen, setSortOpen] = useState(false);
return (
<div className="flex items-center justify-between">
<Button
onClick={onOpenFilters}
variant="ghost"
className="gap-x-2 px-0 font-normal hover:bg-transparent"
>
<RiEqualizerLine size={18} />
Filters
{activeFilterCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
{activeFilterCount}
</span>
)}
</Button>
<div className="flex items-center gap-x-4">
{typeof totalCount === 'number' && (
<span className="text-sm text-muted-foreground tabular-nums">
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
</span>
)}
<div className="relative">
<Button
onClick={() => setSortOpen((prev) => !prev)}
variant="ghost"
aria-expanded={sortOpen}
className="gap-x-1 px-0 font-normal hover:bg-transparent"
>
Sort
<RiArrowDownSLine size={16} />
</Button>
{sortOpen && (
<>
{/* Click-away layer sits under the menu, above the page. */}
<div
className="fixed inset-0 z-40"
onClick={() => setSortOpen(false)}
/>
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
{sortOptions.map((option, index) => (
<button
key={option.label}
onClick={() => {
onSortChange(index);
setSortOpen(false);
}}
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
>
{option.label}
{index === sortIndex && <RiCheckLine size={16} />}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
);
};
export default ProductToolbar;
+209
View File
@@ -0,0 +1,209 @@
'use client';
import React, { useState, useEffect } from 'react';
import ProductCard from './product-card';
import { getProductsPage } from '@/hooks/use-shopify-products';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
interface ProductImage {
url: string;
altText?: string | null;
}
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;
subtitle?: string;
limit?: number;
showLoadMore?: boolean;
}
const Products: React.FC<ProductsProps> = ({
title = 'Shopify Hydrogen Storefront',
subtitle = 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
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 [cursor, setCursor] = useState<string | null>(null);
// Paging is cursor-based: without `after`, Shopify returns the same first
// page every time and "load more" appends nothing.
const fetchProducts = async (loadMore = false) => {
try {
if (loadMore) {
setLoadingMore(true);
} else {
setLoading(true);
setError(null);
}
const page = await getProductsPage({
first: limit,
after: loadMore ? cursor : null,
sortKey: 'CREATED_AT',
reverse: true,
});
setProducts((prev) => {
if (!loadMore) return page.products;
const existingIds = new Set(prev.map((p) => p.id));
return [
...prev,
...page.products.filter((p) => !existingIds.has(p.id)),
];
});
setCursor(page.endCursor);
setHasMoreProducts(page.hasNextPage);
} 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 handleLoadMore = () => {
if (!loadingMore && hasMoreProducts) {
fetchProducts(true);
}
};
if (loading) {
return (
<div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
<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="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 bg-zinc-200 w-4/5"></div>
<div className="h-4 bg-zinc-200 w-1/4"></div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
if (error || products.length === 0) {
return (
<div className="py-20 bg-background">
<div className="max-w-screen-2xl mx-auto px-8 text-center">
<h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
{subtitle}
</p>
<p className="text-sm text-muted-foreground mb-6">
{error ||
'Our curated collection is being prepared. Please check back shortly.'}
</p>
{error && (
<Button
onClick={() => fetchProducts()}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
Try again
</Button>
)}
</div>
</div>
);
}
return (
<div className="py-20">
<div className="max-w-screen-2xl mx-auto px-8">
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
{title}
</h2>
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
{subtitle}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16 mb-20">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{showLoadMore && hasMoreProducts && (
<div className="flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</div>
</div>
);
};
export default Products;
+206
View File
@@ -0,0 +1,206 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from '@/components/ui/command';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
import {
searchSuggestions,
type SearchSuggestion,
} from '@/hooks/use-shopify-search';
const DEBOUNCE_MS = 250;
const SUGGESTION_COUNT = 3;
const formatPrice = (amount: string) => `$${parseFloat(amount).toFixed(2)}`;
const SearchDialog: React.FC = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState('');
const [results, setResults] = useState<SearchSuggestion[]>([]);
const [searching, setSearching] = useState(false);
// Guards against a slow early request overwriting a newer one's results.
const requestId = useRef(0);
useEffect(() => {
const query = term.trim();
if (!query) {
setResults([]);
setSearching(false);
return;
}
setSearching(true);
const id = ++requestId.current;
const timer = setTimeout(async () => {
try {
const { products } = await searchSuggestions(query, SUGGESTION_COUNT);
if (id !== requestId.current) return;
setResults(products);
} catch (err) {
console.error('Search failed:', err);
if (id === requestId.current) setResults([]);
} finally {
if (id === requestId.current) setSearching(false);
}
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [term]);
const close = () => {
setOpen(false);
setTerm('');
setResults([]);
};
const goToSearchPage = () => {
const query = term.trim();
close();
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search');
};
const goToProduct = (handle: string) => {
close();
router.push(`/products/${handle}`);
};
const hasQuery = Boolean(term.trim());
return (
<>
<Button
onClick={() => setOpen(true)}
variant="ghost"
size="icon"
aria-label="Search"
className="rounded-full"
>
<RiSearchLine className="size-5" />
</Button>
<CommandDialog
open={open}
onOpenChange={(next) => !next && close()}
title="Search"
description="Search products"
showCloseButton={false}
className="top-24 max-w-xl translate-y-0"
// Results come from the Storefront API, so cmdk must not re-filter them.
shouldFilter={false}
>
<div className="relative">
<CommandInput
value={term}
onValueChange={setTerm}
placeholder="Search products"
className="pr-28"
onKeyDown={(event) => {
if (event.key === 'Enter') goToSearchPage();
}}
/>
<div className="absolute right-2 top-0 flex h-12 items-center gap-1">
{hasQuery && (
<Button
onClick={() => setTerm('')}
variant="ghost"
size="sm"
className="font-normal text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
<Button
onClick={close}
variant="ghost"
size="icon-sm"
aria-label="Close search"
>
<RiCloseLine className="size-4" />
</Button>
</div>
</div>
{hasQuery && (
<>
<div className="px-3 pt-3">
<button
onClick={goToSearchPage}
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
>
{term.trim()}
</button>
</div>
<CommandList className="max-h-80">
{searching && results.length === 0 ? (
<div className="flex justify-center py-8">
<Loader size={20} />
</div>
) : results.length === 0 ? (
<CommandEmpty>No products found.</CommandEmpty>
) : (
<CommandGroup heading="Products">
{results.map((product) => (
<CommandItem
key={product.id}
value={product.handle}
onSelect={() => goToProduct(product.handle)}
className="gap-3 py-2"
>
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
{product.featuredImage ? (
<img
src={product.featuredImage.url}
alt={product.featuredImage.altText || product.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-400">
<RiImageLine className="size-4" />
</div>
)}
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">
{product.title}
</div>
<div className="font-mono text-sm tabular-nums tracking-tight text-foreground">
{formatPrice(
product.priceRange.minVariantPrice.amount
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
{results.length > 0 && (
<div className="flex justify-center px-4 pb-5 pt-2">
<Button onClick={goToSearchPage} className="px-8">
View All
</Button>
</div>
)}
</>
)}
</CommandDialog>
</>
);
};
export default SearchDialog;
+197
View File
@@ -0,0 +1,197 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import ProductCard from './product-card';
import ProductFilters from './product-filters';
import ProductToolbar from './product-toolbar';
import { Button } from '@/components/ui/button';
import { Loader } from '@/components/ui/loader';
import {
searchProducts,
type SearchFilter,
type SearchSortKey,
} from '@/hooks/use-shopify-search';
import type { Product } from '@/hooks/use-shopify-products';
const PAGE_SIZE = 24;
interface SortOption {
label: string;
sortKey: SearchSortKey;
reverse: boolean;
}
const SORT_OPTIONS: SortOption[] = [
{ label: 'Best Matches', sortKey: 'RELEVANCE', reverse: false },
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
];
const SearchResults: React.FC = () => {
const searchParams = useSearchParams();
const query = searchParams.get('q') ?? '';
const [products, setProducts] = useState<Product[]>([]);
const [filters, setFilters] = useState<SearchFilter[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasNextPage, setHasNextPage] = useState(false);
const [sortIndex, setSortIndex] = useState(0);
const [filtersOpen, setFiltersOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const sort = SORT_OPTIONS[sortIndex];
// Serialised so the effect re-runs when the selection changes, not the array.
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
setLoading(true);
setError(null);
const result = await searchProducts({
// An empty term still returns the catalogue, which is what an
// unqualified /search visit should show.
query,
first: PAGE_SIZE,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
if (cancelled) return;
setProducts(result.products);
setTotalCount(result.totalCount);
setCursor(result.endCursor);
setHasNextPage(result.hasNextPage);
// Facet counts change with the result set, but keep the panel stable
// while filters are applied so options don't vanish mid-selection.
if (activeFilters.length === 0) setFilters(result.filters);
} catch (err) {
if (cancelled) return;
console.error('Search failed:', err);
setError(err instanceof Error ? err.message : 'Search failed');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
return () => {
cancelled = true;
};
}, [query, sort.sortKey, sort.reverse, activeKey]);
const handleLoadMore = async () => {
if (loadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const result = await searchProducts({
query,
first: PAGE_SIZE,
after: cursor,
sortKey: sort.sortKey,
reverse: sort.reverse,
filterInputs: activeFilters,
});
setProducts((prev) => {
const seen = new Set(prev.map((p) => p.id));
return [...prev, ...result.products.filter((p) => !seen.has(p.id))];
});
setCursor(result.endCursor);
setHasNextPage(result.hasNextPage);
} catch (err) {
console.error('Failed to load more results:', err);
} finally {
setLoadingMore(false);
}
};
return (
<section className="bg-background py-10">
<div className="max-w-screen-2xl mx-auto px-8">
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
Search
</h1>
<div className="mt-6">
<ProductToolbar
totalCount={totalCount}
onOpenFilters={() => setFiltersOpen(true)}
sortOptions={SORT_OPTIONS}
sortIndex={sortIndex}
onSortChange={setSortIndex}
activeFilterCount={activeFilters.length}
/>
</div>
{/* Results */}
<div className="mt-8">
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="animate-pulse">
<div className="aspect-square bg-zinc-100"></div>
<div className="pt-4 space-y-2">
<div className="h-4 w-4/5 bg-zinc-200"></div>
<div className="h-4 w-1/4 bg-zinc-200"></div>
</div>
</div>
))}
</div>
) : error ? (
<p className="text-sm text-muted-foreground">{error}</p>
) : products.length === 0 ? (
<p className="text-sm text-muted-foreground">
No products matched{query ? `${query}` : ' your filters'}.
</p>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div className="mt-16 flex justify-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
className="font-normal text-muted-foreground hover:text-foreground"
>
{loadingMore && <Loader size={16} />}
{loadingMore ? 'Loading' : 'Load more'}
</Button>
</div>
)}
</>
)}
</div>
</div>
<ProductFilters
open={filtersOpen}
onOpenChange={setFiltersOpen}
filters={filters}
activeFilters={activeFilters}
onActiveFiltersChange={setActiveFilters}
/>
</section>
);
};
export default SearchResults;
+152
View File
@@ -0,0 +1,152 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { RiArrowDownSLine } from '@remixicon/react';
import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections';
import { cn } from '@/lib/utils';
interface ShopMenuProps {
label?: string;
/** Renders inline inside the mobile menu instead of as a floating panel. */
mobile?: boolean;
/** Fires after a collection is picked, so the mobile menu can close itself. */
onNavigate?: () => void;
}
const ShopMenu: React.FC<ShopMenuProps> = ({
label = 'Shop',
mobile = false,
onNavigate,
}) => {
const [open, setOpen] = useState(false);
const { collections, loading, error, load } = useCollectionsOnDemand();
// The collection list is only worth fetching once someone opens the menu.
const toggle = () => {
const next = !open;
setOpen(next);
if (next) load();
};
const close = () => {
setOpen(false);
onNavigate?.();
};
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open]);
const itemClasses = cn(
'block text-sm text-foreground hover:bg-accent transition-colors',
mobile ? 'px-3 py-2' : 'px-4 py-2'
);
const body = (
<>
{loading &&
Array.from({ length: 5 }).map((_, index) => (
<div key={index} className={cn(itemClasses, 'py-2.5')}>
<div className="h-3 w-2/3 animate-pulse bg-muted" />
</div>
))}
{error && (
<div className={cn(itemClasses, 'hover:bg-transparent')}>
<p className="text-muted-foreground">{error}</p>
<button
onClick={load}
className="mt-1 underline underline-offset-2 hover:text-muted-foreground"
>
Try again
</button>
</div>
)}
{!loading && !error && collections.length === 0 && (
<p className={cn(itemClasses, 'text-muted-foreground hover:bg-transparent')}>
No collections yet.
</p>
)}
{collections.map((collection) => (
<Link
key={collection.id}
href={`/collections/${collection.handle}`}
onClick={close}
className={itemClasses}
>
{collection.title}
</Link>
))}
{collections.length > 0 && (
<>
<div className="my-1 h-px bg-border" />
<Link
href="/collections"
onClick={close}
className={cn(itemClasses, 'text-muted-foreground')}
>
View all collections
</Link>
</>
)}
</>
);
const trigger = (
<button
onClick={toggle}
aria-expanded={open}
className={cn(
'flex items-center gap-x-1 text-foreground hover:text-muted-foreground transition-colors',
mobile && 'w-full justify-between'
)}
>
<span>{label}</span>
<RiArrowDownSLine
className={cn('size-4 transition-transform', open && 'rotate-180')}
/>
</button>
);
if (mobile) {
return (
<div>
{trigger}
{open && (
<div className="mt-2 flex flex-col border-l border-border pl-1">
{body}
</div>
)}
</div>
);
}
return (
<div className="relative">
{trigger}
{open && (
<>
{/* Catches the click that dismisses the panel. */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
{body}
</div>
</>
)}
</div>
);
};
export default ShopMenu;
+100
View File
@@ -0,0 +1,100 @@
'use client';
import React from 'react';
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/config';
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
import { Loader } from '@/components/ui/loader';
import { Button } from '@/components/ui/button';
export interface ShopPayVariant {
id: string;
quantity?: number;
}
interface ShopPayButtonProps {
variants: ShopPayVariant[];
disabled?: boolean;
loading?: boolean;
className?: string;
/** Used when no permalink can be built (missing domain or unusable IDs). */
onFallbackClick?: () => void;
}
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
function toNumericVariantId(id: string): string | null {
const trimmed = id.trim();
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
if (gid) return gid[1];
return /^\d+$/.test(trimmed) ? trimmed : null;
}
function toStoreUrl(domain?: string): string | null {
if (!domain) return null;
try {
return new URL(domain.startsWith('http') ? domain : `https://${domain}`)
.origin;
} catch {
return null;
}
}
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
if (!storeUrl || variants.length === 0) return null;
const lines: string[] = [];
for (const { id, quantity = 1 } of variants) {
const numericId = toNumericVariantId(id);
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
lines.push(`${numericId}:${quantity}`);
}
return `${storeUrl}/cart/${lines.join(',')}?payment=shop_pay`;
}
const BUTTON_CLASSES =
'flex h-11 w-full cursor-pointer items-center justify-center rounded-md bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50';
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
variants,
disabled = false,
loading = false,
className = '',
onFallbackClick,
}) => {
const shopPayUrl = buildShopPayUrl(variants);
const contents = (
<>
<span className="sr-only">Buy with</span>
{loading ? <Loader size={16} /> : <ShopPayLogo />}
</>
);
// An anchor keeps the checkout URL visible, openable in a new tab, and
// navigable without JS; the button only stands in when there's no URL.
if (shopPayUrl && !disabled) {
return (
<a
href={shopPayUrl}
className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</a>
);
}
return (
<Button
type="button"
onClick={onFallbackClick}
disabled={disabled || (!shopPayUrl && !onFallbackClick)}
className={`${BUTTON_CLASSES} ${className}`.trim()}
>
{contents}
</Button>
);
};
export default ShopPayButton;
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
// data, so the button needs no additional text beyond a screen-reader label.
const ShopPayLogo: React.FC<{ className?: string }> = ({
className = 'h-auto w-[98px]',
}) => (
<svg
fill="none"
viewBox="0 0 10885 2079"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className={className}
>
<path
d="M158.355 1621V448.811H637.207C856.681 448.811 994.683 565.198 994.683 748.093C994.683 874.457 923.188 967.567 800.15 1004.15V1010.8C943.14 1039.06 1024.61 1145.47 1024.61 1296.78C1024.61 1494.64 884.946 1621 665.473 1621H158.355ZM630.556 1459.72C745.281 1459.72 813.451 1391.55 813.451 1278.49C813.451 1162.1 743.619 1093.93 630.556 1093.93H362.865V1459.72H630.556ZM605.616 939.301C713.69 939.301 781.86 874.457 781.86 774.696C781.86 671.61 713.69 610.091 605.616 610.091H362.865V939.301H605.616ZM1486.02 1645.94C1328.06 1645.94 1200.04 1547.84 1200.04 1323.38V764.72H1394.57V1290.13C1394.57 1411.5 1456.09 1479.67 1557.51 1479.67C1675.56 1479.67 1742.07 1394.88 1742.07 1268.51V764.72H1938.27V1621H1750.38V1501.29H1742.07C1693.85 1599.39 1604.07 1645.94 1486.02 1645.94ZM2229.79 1895.34L2392.74 1537.87L2053.55 764.72H2271.36L2430.98 1167.09C2455.92 1233.6 2474.21 1288.46 2492.5 1356.63H2499.15C2515.78 1290.13 2534.06 1231.93 2557.34 1167.09L2716.96 764.72H2934.77L2445.94 1895.34H2229.79ZM3535.97 1621L3266.62 764.72H3472.79L3585.85 1185.38C3607.47 1266.85 3624.09 1345 3639.06 1429.79H3645.71C3662.34 1343.33 3677.3 1271.84 3702.24 1185.38L3820.29 764.72H4021.47L4139.53 1185.38C4164.47 1273.5 4181.09 1348.32 4196.06 1429.79H4204.37C4217.67 1346.66 4234.3 1270.17 4255.91 1185.38L4368.97 764.72H4576.81L4305.79 1621H4104.61L3984.9 1195.35C3958.29 1102.24 3941.67 1029.09 3925.04 942.627H3918.39C3900.1 1030.75 3883.47 1103.91 3856.87 1195.35L3738.82 1621H3535.97ZM4696.08 1621V764.72H4890.61V1621H4696.08ZM4794.18 633.368C4724.35 633.368 4672.8 578.5 4672.8 510.33C4672.8 442.16 4726.01 388.954 4794.18 388.954C4864.01 388.954 4917.22 442.16 4917.22 510.33C4917.22 580.162 4864.01 633.368 4794.18 633.368ZM5389.5 1637.63C5249.83 1637.63 5163.37 1572.78 5163.37 1426.47V926H5025.37V776.359H5111.83C5160.05 776.359 5176.67 758.069 5176.67 709.851V560.21H5359.57V764.72H5520.85V926H5359.57V1363.28C5359.57 1433.12 5384.51 1461.38 5441.04 1461.38C5465.98 1461.38 5489.26 1458.06 5520.85 1451.41V1616.01C5474.29 1630.98 5437.71 1637.63 5389.5 1637.63ZM5694.66 1621V418.883H5890.86V877.782H5897.51C5947.39 784.672 6040.5 739.78 6153.56 739.78C6316.51 739.78 6444.53 837.878 6444.53 1062.34V1621H6248.34V1095.59C6248.34 974.218 6186.82 906.048 6080.4 906.048C5959.03 906.048 5889.2 990.844 5889.2 1117.21V1621H5694.66Z"
fill="currentColor"
/>
<g clipPath="url(#shop-pay-logo-clip)">
<path
d="M7406 1027.33C7247.3 992.071 7176.6 978.274 7176.6 915.639C7176.6 856.727 7224.44 827.38 7320.13 827.38C7404.29 827.38 7465.8 865.049 7511.08 938.853C7514.5 944.547 7521.55 946.518 7527.32 943.452L7705.88 851.032C7712.29 847.747 7714.64 839.425 7711 833.074C7636.89 701.453 7499.98 629.4 7319.71 629.4C7082.83 629.4 6935.67 748.976 6935.67 939.072C6935.67 1140.99 7114.87 1192.02 7273.78 1227.28C7432.7 1262.54 7503.61 1276.34 7503.61 1338.97C7503.61 1401.61 7451.92 1431.17 7348.75 1431.17C7253.49 1431.17 7182.79 1386.5 7140.08 1299.77C7136.87 1293.42 7129.4 1290.79 7123.2 1294.08L6945.07 1384.53C6938.87 1387.81 6936.31 1395.48 6939.51 1402.05C7010.21 1547.68 7155.24 1629.59 7348.97 1629.59C7595.67 1629.59 7744.75 1511.99 7744.75 1315.98C7744.75 1119.97 7564.69 1063.03 7406 1027.77V1027.33Z"
fill="currentColor"
/>
<path
d="M8362.88 629.4C8261.64 629.4 8172.15 666.193 8107.86 731.675C8103.8 735.617 8097.18 732.77 8097.18 727.076V308.997C8097.18 301.77 8091.62 296.076 8084.57 296.076H7861.16C7854.11 296.076 7848.56 301.77 7848.56 308.997V1606.6C7848.56 1613.82 7854.11 1619.52 7861.16 1619.52H8084.57C8091.62 1619.52 8097.18 1613.82 8097.18 1606.6V1037.41C8097.18 927.465 8179.41 843.148 8290.26 843.148C8401.12 843.148 8481.43 925.713 8481.43 1037.41V1606.6C8481.43 1613.82 8486.98 1619.52 8494.03 1619.52H8717.44C8724.49 1619.52 8730.05 1613.82 8730.05 1606.6V1037.41C8730.05 798.253 8577.11 629.4 8362.88 629.4Z"
fill="currentColor"
/>
<path
d="M9183.28 592.17C9061.97 592.17 8948.34 630.276 8866.74 685.246C8861.19 688.969 8859.27 696.634 8862.69 702.548L8961.15 874.904C8964.78 881.036 8972.47 883.226 8978.45 879.503C9040.39 841.177 9111.3 821.248 9183.71 821.686C9378.72 821.686 9522.04 962.725 9522.04 1149.1C9522.04 1307.88 9407.34 1425.48 9261.89 1425.48C9143.34 1425.48 9061.11 1354.74 9061.11 1254.88C9061.11 1197.72 9084.82 1150.85 9146.55 1117.78C9152.95 1114.28 9155.3 1106.17 9151.46 1099.82L9058.55 938.634C9055.56 933.378 9049.15 930.969 9043.38 933.159C8918.86 980.464 8831.5 1094.35 8831.5 1247.21C8831.5 1478.48 9011.13 1651.05 9261.67 1651.05C9554.29 1651.05 9764.68 1443.22 9764.68 1145.16C9764.68 825.628 9519.9 592.17 9183.28 592.17Z"
fill="currentColor"
/>
<path
d="M10418.3 627.429C10305.3 627.429 10204.2 670.354 10130.6 745.692C10126.5 749.853 10119.9 746.787 10119.9 741.092V650.425C10119.9 643.198 10114.3 637.504 10107.3 637.504H9889.63C9882.58 637.504 9877.03 643.198 9877.03 650.425V1946.05C9877.03 1953.28 9882.58 1958.97 9889.63 1958.97H10113C10120.1 1958.97 10125.6 1953.28 10125.6 1946.05V1521.19C10125.6 1515.49 10132.3 1512.64 10136.3 1516.37C10209.8 1586.45 10307 1627.4 10418.3 1627.4C10680.3 1627.4 10884.7 1409.93 10884.7 1127.42C10884.7 844.9 10680.1 627.429 10418.3 627.429ZM10376 1407.96C10226.9 1407.96 10113.9 1286.41 10113.9 1125.66C10113.9 964.915 10226.7 843.367 10376 843.367C10525.3 843.367 10637.8 962.944 10637.8 1125.66C10637.8 1288.38 10526.8 1407.96 10375.8 1407.96H10376Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="shop-pay-logo-clip">
<rect
width="3948.86"
height="1662.68"
fill="white"
transform="translate(6935.67 296.076)"
/>
</clipPath>
</defs>
</svg>
);
export default ShopPayLogo;
+574
View File
@@ -0,0 +1,574 @@
'use client';
import React, { memo, useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message';
import {
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputBody,
PromptInputFooter,
PromptInputProvider,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
usePromptInputAttachments,
type PromptInputMessage,
} from '@/components/ai-elements/prompt-input';
import {
Attachment,
AttachmentHoverCard,
AttachmentHoverCardContent,
AttachmentHoverCardTrigger,
AttachmentInfo,
AttachmentPreview,
AttachmentRemove,
Attachments,
getAttachmentLabel,
getMediaCategory,
type AttachmentData,
} from '@/components/ai-elements/attachments';
import {
Suggestions,
Suggestion,
} from '@/components/ai-elements/suggestion';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { Button } from '@/components/ui/button';
import { RainbowButton } from '@/components/ui/rainbow-button';
import {
RiCloseLine,
RiSearchLine,
RiPriceTag3Line,
RiStore2Line,
RiLayoutGridLine,
RiShoppingBag3Line,
} from '@remixicon/react';
// Feature flag. Written as a static member expression so Next inlines it at
// build time; the assistant is off unless the env var is explicitly "1".
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
const SUGGESTIONS = [
'What do you sell?',
'Show me hoodies under $100',
'What collections are there?',
];
interface ToolProduct {
handle: string;
title: string;
image: string | null;
price?: string;
}
interface ToolSummary {
label: string;
icon: React.ReactNode;
products: ToolProduct[];
}
const LOADING_LABELS: Record<string, string> = {
searchCatalogue: 'Searching the catalogue',
getProductDetails: 'Reading product details',
listCollections: 'Listing collections',
getCollectionProducts: 'Browsing a collection',
browseProducts: 'Browsing new arrivals',
};
const toolName = (type: string) =>
type.startsWith('tool-') ? type.slice(5) : type;
const plural = (count: number, noun: string) =>
`${count} ${noun}${count === 1 ? '' : 's'}`;
// Turns a finished tool result into the one-line summary plus any products
// worth previewing.
const summariseTool = (
name: string,
output: Record<string, unknown> | undefined
): ToolSummary => {
const products = (output?.products as ToolProduct[] | undefined) ?? [];
switch (name) {
case 'searchCatalogue': {
const total = (output?.totalCount as number) ?? products.length;
return {
label: `Found ${plural(total, 'product')}`,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
case 'getProductDetails':
return {
label: output?.found
? `Read ${output.title as string}`
: 'Product not found',
icon: <RiPriceTag3Line className="size-3.5" />,
products: output?.found
? [
{
handle: output.handle as string,
title: output.title as string,
image: (output.image as string) ?? null,
},
]
: [],
};
case 'listCollections': {
const collections =
(output?.collections as Array<unknown> | undefined) ?? [];
return {
label: `Found ${plural(collections.length, 'collection')}`,
icon: <RiStore2Line className="size-3.5" />,
products: [],
};
}
case 'getCollectionProducts':
return {
label: output?.found
? `Found ${plural(products.length, 'product')} in ${output.collection}`
: 'Collection not found',
icon: <RiLayoutGridLine className="size-3.5" />,
products,
};
case 'browseProducts':
return {
label: `Browsed ${plural(products.length, 'new arrival')}`,
icon: <RiShoppingBag3Line className="size-3.5" />,
products,
};
default:
return {
label: name,
icon: <RiSearchLine className="size-3.5" />,
products,
};
}
};
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
const isInternalLink = (url: string) => {
if (url.startsWith('/')) return true;
try {
return new URL(url, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
};
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
products,
}) => {
const withImages = products.filter((product) => product.image);
if (withImages.length === 0) return null;
return (
<Attachments variant="grid" className="ml-0 mt-2">
{withImages.slice(0, 6).map((product) => (
<Link
key={product.handle}
href={`/products/${product.handle}`}
title={product.title}
className="group/product w-20"
>
<Attachment
data={{
id: product.handle,
type: 'file',
url: product.image as string,
mediaType: 'image/jpeg',
filename: product.title,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
{product.title}
</span>
</Link>
))}
</Attachments>
);
};
interface AttachmentItemProps {
attachment: AttachmentData;
onRemove: (id: string) => void;
}
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
const handleRemove = useCallback(
() => onRemove(attachment.id),
[onRemove, attachment.id]
);
const mediaCategory = getMediaCategory(attachment);
const label = getAttachmentLabel(attachment);
return (
<AttachmentHoverCard key={attachment.id}>
<AttachmentHoverCardTrigger asChild>
<Attachment data={attachment} onRemove={handleRemove}>
{/* Thumbnail swaps to the remove button on hover. */}
<div className="group relative size-5 shrink-0">
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
<AttachmentPreview />
</div>
<AttachmentRemove className="absolute inset-0" />
</div>
<AttachmentInfo />
</Attachment>
</AttachmentHoverCardTrigger>
<AttachmentHoverCardContent>
<div className="space-y-2">
{mediaCategory === 'image' &&
attachment.type === 'file' &&
attachment.url && (
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
<img
alt={label}
className="max-h-full max-w-full object-contain"
height={384}
src={attachment.url}
width={320}
/>
</div>
)}
<div className="space-y-1 px-0.5">
<h4 className="text-sm font-semibold leading-none">{label}</h4>
{attachment.mediaType && (
<p className="font-mono text-xs text-muted-foreground">
{attachment.mediaType}
</p>
)}
</div>
</div>
</AttachmentHoverCardContent>
</AttachmentHoverCard>
);
});
AttachmentItem.displayName = 'AttachmentItem';
// Pending uploads, shown inline above the textarea.
const PromptInputAttachmentsDisplay = () => {
const attachments = usePromptInputAttachments();
const handleRemove = useCallback(
(id: string) => attachments.remove(id),
[attachments]
);
if (attachments.files.length === 0) return null;
return (
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
{attachments.files.map((attachment) => (
<AttachmentItem
attachment={attachment}
key={attachment.id}
onRemove={handleRemove}
/>
))}
</Attachments>
);
};
const StoreAssistant: React.FC = () => {
// Returns before any hooks run — safe because the flag is a build-time
// constant and cannot change between renders.
if (!AI_ENABLED) return null;
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [input, setInput] = useState('');
// Drives the launcher's slide-in on first paint.
useEffect(() => setMounted(true), []);
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const isBusy = status === 'submitted' || status === 'streaming';
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
}`;
const send = (text: string) => {
const trimmed = text.trim();
if (!trimmed || isBusy) return;
sendMessage({ text: trimmed });
setInput('');
};
// Attachments arrive on the submitted message, so images go out with it.
const handleSubmit = (message: PromptInputMessage) => {
const text = (message.text ?? input).trim();
const files = message.files ?? [];
if ((!text && files.length === 0) || isBusy) return;
sendMessage({ text, files });
setInput('');
};
return (
<>
{/* Popover panel, anchored above the launcher */}
<div
aria-hidden={!open}
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
open
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-2 opacity-0'
}`}
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
>
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
<span className="text-sm font-medium">Store Assistant</span>
<Button
onClick={() => setOpen(false)}
variant="ghost"
size="icon-sm"
aria-label="Close assistant"
className="rounded-full"
>
<RiCloseLine className="size-4" />
</Button>
</header>
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-3">
{messages.length === 0 && (
<ConversationEmptyState
title="Ask about the store"
description="Find products, compare options, browse collections."
>
{/* w-full + wrap so chips stack in the narrow popover rather
than scrolling off the edge. */}
<Suggestions className="mt-3 w-full flex-wrap justify-center">
{SUGGESTIONS.map((suggestion) => (
<Suggestion
key={suggestion}
onClick={send}
suggestion={suggestion}
className="font-normal"
/>
))}
</Suggestions>
</ConversationEmptyState>
)}
{messages.map((message) => {
const fileParts = message.parts.filter(
(part) => part.type === 'file'
);
return (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return (
<MessageResponse
key={index}
// Only repair half-written markdown while it streams;
// settled text is rendered exactly as sent.
isAnimating={
status === 'streaming' && part.state === 'streaming'
}
linkSafety={{
enabled: true,
onLinkCheck: isInternalLink,
}}
>
{part.text}
</MessageResponse>
);
}
if (part.type === 'reasoning') {
return (
<Reasoning
key={index}
className="w-full"
isStreaming={
status === 'streaming' &&
part.state === 'streaming'
}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type.startsWith('tool-')) {
const toolPart = part as typeof part & {
state: string;
output?: Record<string, unknown>;
errorText?: string;
};
const name = toolName(part.type);
// Shimmer while the call is in flight; a quiet summary
// line once it returns.
if (
toolPart.state === 'input-streaming' ||
toolPart.state === 'input-available'
) {
return (
<Shimmer key={index} className="text-xs">
{LOADING_LABELS[name] ?? name}
</Shimmer>
);
}
if (toolPart.state === 'output-error') {
return (
<p key={index} className="text-xs text-muted-foreground">
Couldn&apos;t load that.
</p>
);
}
const { label, icon, products } = summariseTool(
name,
toolPart.output
);
return (
<div key={index} className="my-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
{label}
</div>
<ProductPreviews products={products} />
</div>
);
}
return null;
})}
{/* Images the shopper attached, shown under their message. */}
{fileParts.length > 0 && (
<Attachments variant="grid" className="ml-0 justify-start">
{fileParts.map((part, index) => (
<Attachment
key={`${message.id}-file-${index}`}
data={{
id: `${message.id}-file-${index}`,
type: 'file',
url: part.url,
mediaType: part.mediaType,
filename: part.filename,
}}
className="size-20"
>
<AttachmentPreview />
</Attachment>
))}
</Attachments>
)}
</MessageContent>
</Message>
);
})}
{status === 'submitted' && (
<Shimmer className="text-xs">Thinking</Shimmer>
)}
{error && (
<p className="text-xs text-destructive">
Something went wrong. Please try again.
</p>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="shrink-0 p-2">
<PromptInputProvider>
<PromptInput
globalDrop
multiple
accept="image/*"
onSubmit={handleSubmit}
className="rounded-lg"
>
<PromptInputAttachmentsDisplay />
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask about products…"
className="min-h-12"
/>
</PromptInputBody>
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</PromptInputProvider>
</div>
</div>
{/* Launcher */}
{/* Rainbow treatment only while the assistant is open; otherwise the
launcher matches the rest of the site's buttons. */}
{open ? (
<RainbowButton
onClick={() => setOpen(false)}
aria-label="Close store assistant"
aria-expanded
size="lg"
className={launcherClasses}
>
Ask
</RainbowButton>
) : (
<Button
onClick={() => setOpen(true)}
aria-label="Open store assistant"
aria-expanded={false}
className={`h-11 ${launcherClasses}`}
>
Ask
</Button>
)}
</>
);
};
export default StoreAssistant;
+197
View File
@@ -0,0 +1,197 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
interface AccordionContextType {
value: string | string[];
onValueChange: (value: string) => void;
type: 'single' | 'multiple';
}
const AccordionContext = createContext<AccordionContextType | undefined>(
undefined
);
interface AccordionItemContextType {
value: string;
}
const AccordionItemContext = createContext<
AccordionItemContextType | undefined
>(undefined);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion components must be used within an Accordion');
}
return context;
}
function useAccordionItem() {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error(
'AccordionTrigger and AccordionContent must be used within an AccordionItem'
);
}
return context;
}
interface AccordionProps {
type?: 'single' | 'multiple';
value?: string | string[];
onValueChange?: (value: string | string[]) => void;
children: React.ReactNode;
}
function Accordion({
type = 'single',
value: controlledValue,
onValueChange,
children,
}: AccordionProps) {
const [internalValue, setInternalValue] = useState<string | string[]>(
type === 'single' ? '' : []
);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const handleValueChange = useCallback(
(itemValue: string) => {
if (type === 'single') {
const newValue = value === itemValue ? '' : itemValue;
if (!isControlled) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
} else {
const valueArray = Array.isArray(value) ? value : [];
const newValue = valueArray.includes(itemValue)
? valueArray.filter((v) => v !== itemValue)
: [...valueArray, itemValue];
if (!isControlled) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
}
},
[value, type, isControlled, onValueChange]
);
return (
<AccordionContext.Provider
value={{ value, onValueChange: handleValueChange, type }}
>
<div data-slot="accordion">{children}</div>
</AccordionContext.Provider>
);
}
interface AccordionItemProps {
value: string;
children: React.ReactNode;
className?: string;
}
function AccordionItem({ value, children, className }: AccordionItemProps) {
return (
<AccordionItemContext.Provider value={{ value }}>
<div
data-slot="accordion-item"
className={cn('border-b border-border last:border-b-0', className)}
data-value={value}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
}
function AccordionTrigger({
className,
children,
...props
}: AccordionTriggerProps) {
const accordion = useAccordion();
const item = useAccordionItem();
const handleClick = () => {
accordion.onValueChange(item.value);
};
const isOpen =
accordion.type === 'single'
? accordion.value === item.value
: Array.isArray(accordion.value) && accordion.value.includes(item.value);
return (
<div className="flex">
<button
data-slot="accordion-trigger"
className={cn(
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 px-0 text-left text-sm font-medium transition-all outline-none hover:cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-md disabled:pointer-events-none disabled:opacity-50',
isOpen && '[&>svg]:rotate-180',
className
)}
onClick={handleClick}
data-state={isOpen ? 'open' : 'closed'}
{...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="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
);
}
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function AccordionContent({
className,
children,
...props
}: AccordionContentProps) {
const accordion = useAccordion();
const item = useAccordionItem();
const isOpen =
accordion.type === 'single'
? accordion.value === item.value
: Array.isArray(accordion.value) && accordion.value.includes(item.value);
return (
<div
data-slot="accordion-content"
data-state={isOpen ? 'open' : 'closed'}
className={cn(
'overflow-hidden text-sm transition-all duration-200',
isOpen ? 'max-h-96' : 'max-h-0'
)}
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</div>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { cn } from "@/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 }
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface AvatarProps extends React.ComponentProps<'div'> {
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const sizeClasses = {
sm: 'size-6',
md: 'size-8',
lg: 'size-10',
xl: 'size-12',
};
function Avatar({ className, size = 'md', ...props }: AvatarProps) {
const [imageError, setImageError] = React.useState(false);
return (
<div
data-slot="avatar"
className={cn(
'relative flex shrink-0 overflow-hidden rounded-full',
sizeClasses[size],
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
onError,
...props
}: React.ComponentProps<'img'>) {
const [hasError, setHasError] = React.useState(false);
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
setHasError(true);
onError?.(e as any);
};
if (hasError) {
return null;
}
return (
<img
data-slot="avatar-image"
className={cn('aspect-square h-full w-full object-cover', className)}
onError={handleError}
{...props}
/>
);
}
interface AvatarFallbackProps extends React.ComponentProps<'div'> {
children: React.ReactNode;
}
function AvatarFallback({
className,
children,
...props
}: AvatarFallbackProps) {
return (
<div
data-slot="avatar-fallback"
className={cn(
'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full font-medium text-sm',
className
)}
{...props}
>
{children}
</div>
);
}
export { Avatar, AvatarImage, AvatarFallback };
export type { AvatarProps };
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
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 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import { cn } from '@/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)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
className: cn(
'hover:text-foreground transition-colors',
child.props.className,
className
),
...props,
} as any);
}
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,
};
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
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",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative m-0! self-stretch bg-input data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/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 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-2 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", 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
View File
@@ -0,0 +1,245 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
} from 'react';
import { cn } from '@/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,
};
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+195
View File
@@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
"transition-none animate-none",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
// Forwarded to the inner Command so callers can drive filtering themselves
// (e.g. results already filtered server-side).
shouldFilter,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
shouldFilter?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"overflow-hidden p-0",
// No fade/zoom on the command palette — it should appear instantly.
"transition-none animate-none duration-0",
"data-[state=closed]:animate-none data-[state=open]:animate-none",
className
)}
showCloseButton={showCloseButton}
>
<Command shouldFilter={shouldFilter} className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+105
View File
@@ -0,0 +1,105 @@
import React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/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,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+170
View File
@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50",
// Error state.
"has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { cn } from '@/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
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+213
View 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,
};
+96
View File
@@ -0,0 +1,96 @@
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "react";
interface LoaderIconProps {
size?: number;
}
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
<svg
height={size}
strokeLinejoin="round"
style={{ color: "currentcolor" }}
viewBox="0 0 16 16"
width={size}
>
<title>Loader</title>
<g clipPath="url(#clip0_2393_1490)">
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
<path
d="M8 16V12"
opacity="0.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 1.52783L5.64887 4.7639"
opacity="0.9"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 1.52783L10.3511 4.7639"
opacity="0.1"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M12.7023 14.472L10.3511 11.236"
opacity="0.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M3.29773 14.472L5.64887 11.236"
opacity="0.6"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 5.52783L11.8043 6.7639"
opacity="0.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 10.472L4.19583 9.23598"
opacity="0.7"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M15.6085 10.4722L11.8043 9.2361"
opacity="0.3"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M0.391602 5.52783L4.19583 6.7639"
opacity="0.8"
stroke="currentColor"
strokeWidth="1.5"
/>
</g>
<defs>
<clipPath id="clip0_2393_1490">
<rect fill="white" height="16" width="16" />
</clipPath>
</defs>
</svg>
);
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
size?: number;
};
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
<div
className={cn(
"inline-flex animate-spin items-center justify-center",
className
)}
{...props}
>
<LoaderIcon size={size} />
</div>
);
+128
View File
@@ -0,0 +1,128 @@
import * as React from 'react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/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(
'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',
isActive
? 'border border-border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
size === 'icon' && 'size-9',
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
View 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 };
+61
View File
@@ -0,0 +1,61 @@
import React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const rainbowButtonVariants = cva(
cn(
"relative cursor-pointer group transition-all animate-rainbow",
"inline-flex items-center justify-center gap-2 shrink-0",
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
"text-sm font-medium whitespace-nowrap",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
),
{
variants: {
variant: {
default:
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
outline:
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:bg-[length:200%] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-xl px-3 text-xs",
lg: "h-11 rounded-xl px-8",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface RainbowButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof rainbowButtonVariants> {
asChild?: boolean
}
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(rainbowButtonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
RainbowButton.displayName = "RainbowButton"
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
+44
View File
@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import * as RemixIcons from '@remixicon/react';
interface RemixIconProps {
name: string; // e.g. "RiTruckLine"
className?: string;
size?: number;
}
const RemixIcon: React.FC<RemixIconProps> = ({
name,
className,
size = 16,
}) => {
if (!name) return null;
// Normalise: accept "RiTruckLine", "ri-truck-line", or "riTruckLine"
const normalised = name
// ri-truck-line → RiTruckLine
.replace(/^ri-/, 'Ri')
.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
// ensure first char is uppercase
.replace(/^./, (c) => c.toUpperCase());
const IconComponent = (
RemixIcons as Record<
string,
React.FC<{ className?: string; size?: number }>
>
)[normalised];
if (!IconComponent) {
console.warn(
`[RemixIcon] Icon "${name}" (resolved: "${normalised}") not found.`
);
return null;
}
return <IconComponent className={className} size={size} />;
};
export default RemixIcon;
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"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 focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/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(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+308
View File
@@ -0,0 +1,308 @@
import React, { useState, useCallback, useContext, createContext } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/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)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
...rest,
onClick: (e: React.MouseEvent) => {
setOpen(true);
child.props.onClick?.(e);
},
} as any);
}
return (
<button
data-slot="sheet-trigger"
{...rest}
onClick={(e) => {
setOpen(true);
props.onClick?.(e);
}}
>
{children}
</button>
);
}
function SheetPortal({ children }: { children: React.ReactNode }) {
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 as any)}
/>
);
}
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
showCloseButton?: boolean;
}
function SheetContent({
className,
children,
showCloseButton = true,
...props
}: SheetContentProps) {
const { 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>
<SheetOverlay />
<motion.div
data-slot="sheet-content"
className={cn(
'bg-background fixed z-50 flex flex-col gap-0 shadow-lg',
sideClasses[side]
)}
variants={slideVariants[side]}
initial="initial"
animate="animate"
exit="exit"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
{...(props as any)}
>
{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>
</SheetPortal>
);
}
function SheetClose(
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }
) {
const { setOpen } = useSheet();
const { children, asChild, ...rest } = props;
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<any>;
return React.cloneElement(child, {
...rest,
onClick: (e: React.MouseEvent) => {
setOpen(false);
child.props.onClick?.(e);
},
} as any);
}
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
View File
@@ -0,0 +1,13 @@
import { cn } from "@/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
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { toast } from 'sonner';
import { Button } from '@/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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import { cn } from '@/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
View File
@@ -0,0 +1,113 @@
import React from 'react';
import { cn } from '@/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,
};
+143
View File
@@ -0,0 +1,143 @@
import React, { createContext, useContext, useState } from 'react';
import { cn } from '@/lib/utils';
interface TabsContextType {
activeTab: string;
setActiveTab: (value: string) => void;
}
const TabsContext = createContext<TabsContextType | undefined>(undefined);
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs components must be used within a Tabs component');
}
return context;
}
interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
}
function Tabs({
className,
defaultValue,
value: controlledValue,
onValueChange,
children,
...props
}: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue || '');
const isControlled = controlledValue !== undefined;
const activeTab = isControlled ? controlledValue : internalValue;
const handleValueChange = (newValue: string) => {
if (!isControlled) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
};
return (
<TabsContext.Provider
value={{ activeTab, setActiveTab: handleValueChange }}
>
<div
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
>
{children}
</div>
</TabsContext.Provider>
);
}
interface TabsListProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function TabsList({ className, children, ...props }: TabsListProps) {
return (
<div
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
)}
role="tablist"
{...props}
>
{children}
</div>
);
}
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
children: React.ReactNode;
}
function TabsTrigger({
className,
value,
children,
...props
}: TabsTriggerProps) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === value;
return (
<button
data-slot="tabs-trigger"
role="tab"
aria-selected={isActive}
aria-controls={`tabs-content-${value}`}
className={cn(
'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 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
isActive &&
'bg-background dark:bg-input/30 dark:border-input shadow-sm',
className
)}
onClick={() => setActiveTab(value)}
{...props}
>
{children}
</button>
);
}
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
children: React.ReactNode;
}
function TabsContent({
className,
value,
children,
...props
}: TabsContentProps) {
const { activeTab } = useTabs();
if (activeTab !== value) {
return null;
}
return (
<div
data-slot="tabs-content"
role="tabpanel"
id={`tabs-content-${value}`}
className={cn('flex-1 outline-none', className)}
{...props}
>
{children}
</div>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

Some files were not shown because too many files have changed in this diff Show More