Update React Editor integration and clean up template
This commit is contained in:
@@ -20,10 +20,6 @@ import {
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentData =
|
||||
| (FileUIPart & { id: string })
|
||||
| (SourceDocumentUIPart & { id: string });
|
||||
@@ -47,10 +43,6 @@ const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
|
||||
video: VideoIcon,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export const getMediaCategory = (
|
||||
data: AttachmentData
|
||||
): AttachmentMediaCategory => {
|
||||
@@ -108,10 +100,6 @@ const renderAttachmentImage = (
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Contexts
|
||||
// ============================================================================
|
||||
|
||||
interface AttachmentsContextValue {
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
@@ -127,10 +115,6 @@ interface AttachmentContextValue {
|
||||
|
||||
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useAttachmentsContext = () =>
|
||||
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
||||
|
||||
@@ -142,10 +126,6 @@ export const useAttachmentContext = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachments - Container
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: AttachmentVariant;
|
||||
};
|
||||
@@ -175,10 +155,6 @@ export const Attachments = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachment - Item
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: AttachmentData;
|
||||
onRemove?: () => void;
|
||||
@@ -225,10 +201,6 @@ export const Attachment = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentPreview - Media preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||
fallbackIcon?: ReactNode;
|
||||
};
|
||||
@@ -275,10 +247,6 @@ export const AttachmentPreview = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentInfo - Name and type display
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showMediaType?: boolean;
|
||||
};
|
||||
@@ -307,10 +275,6 @@ export const AttachmentInfo = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentRemove - Remove button
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||
label?: string;
|
||||
};
|
||||
@@ -365,10 +329,6 @@ export const AttachmentRemove = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentHoverCard - Hover preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const AttachmentHoverCard = ({
|
||||
@@ -403,10 +363,6 @@ export const AttachmentHoverCardContent = ({
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentEmpty - Empty state
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const AttachmentEmpty = ({
|
||||
|
||||
@@ -24,16 +24,12 @@ 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.
|
||||
// Sanitize model output and reject unsafe URL protocols.
|
||||
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.
|
||||
// Preserve checked state for sanitized GFM task lists.
|
||||
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
|
||||
},
|
||||
};
|
||||
@@ -44,22 +40,15 @@ const processor = unified()
|
||||
.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.
|
||||
// Keep incomplete links as text while streaming.
|
||||
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.
|
||||
// Repair only streaming text; remend can corrupt settled italicized prices.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -71,9 +60,6 @@ type AnchorProps = ComponentProps<"a"> & {
|
||||
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 = ({
|
||||
@@ -82,8 +68,6 @@ const MarkdownLink = ({
|
||||
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;
|
||||
@@ -95,8 +79,6 @@ const MarkdownLink = ({
|
||||
[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} />;
|
||||
}
|
||||
@@ -113,7 +95,6 @@ const MarkdownLink = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
|
||||
const buildComponents = (
|
||||
linkSafety: LinkSafetyConfig | undefined,
|
||||
onUntrusted: (url: string) => void
|
||||
@@ -128,8 +109,6 @@ const buildComponents = (
|
||||
/>
|
||||
),
|
||||
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-");
|
||||
|
||||
@@ -167,8 +146,6 @@ const buildComponents = (
|
||||
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} />
|
||||
),
|
||||
@@ -213,7 +190,6 @@ const buildComponents = (
|
||||
|
||||
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||
children: string;
|
||||
/** True while tokens are still arriving; enables incomplete-syntax repair. */
|
||||
isAnimating?: boolean;
|
||||
linkSafety?: LinkSafetyConfig;
|
||||
};
|
||||
@@ -231,8 +207,6 @@ export const Markdown = memo(
|
||||
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;
|
||||
|
||||
|
||||
@@ -202,7 +202,6 @@ export const MessageBranchContent = ({
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
@@ -231,7 +230,6 @@ export const MessageBranchSelector = ({
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -317,7 +315,6 @@ export const MessageBranchPage = ({
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Markdown>;
|
||||
|
||||
// Markdown already memoises on children/isAnimating.
|
||||
export const MessageResponse = Markdown;
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
@@ -74,15 +74,10 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
// FileReader uses callback-based API, wrapping in Promise is necessary
|
||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
@@ -118,7 +113,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
||||
|
||||
video.srcObject = stream;
|
||||
|
||||
// Video element uses callback-based API, wrapping in Promise is necessary
|
||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
||||
@@ -144,7 +138,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
|
||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, "image/png");
|
||||
@@ -174,10 +167,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Provider Context & Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AttachmentsContext {
|
||||
files: (FileUIPart & { id: string })[];
|
||||
add: (files: File[] | FileList) => void;
|
||||
@@ -196,7 +185,6 @@ export interface TextInputContext {
|
||||
export interface PromptInputControllerProps {
|
||||
textInput: TextInputContext;
|
||||
attachments: AttachmentsContext;
|
||||
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
||||
__registerFileInput: (
|
||||
ref: RefObject<HTMLInputElement | null>,
|
||||
open: () => void
|
||||
@@ -220,7 +208,6 @@ export const usePromptInputController = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
||||
const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
|
||||
@@ -241,19 +228,14 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
||||
* If you don't use it, PromptInput stays fully self-managed.
|
||||
*/
|
||||
/** Lifts prompt state when shared control is needed. */
|
||||
export const PromptInputProvider = ({
|
||||
initialInput: initialTextInput = "",
|
||||
children,
|
||||
}: PromptInputProviderProps) => {
|
||||
// ----- textInput state
|
||||
const [textInput, setTextInput] = useState(initialTextInput);
|
||||
const clearInput = useCallback(() => setTextInput(""), []);
|
||||
|
||||
// ----- attachments state (global when wrapped)
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(FileUIPart & { id: string })[]
|
||||
>([]);
|
||||
@@ -300,14 +282,13 @@ export const PromptInputProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
// Keep the latest files available to the unmount cleanup.
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
|
||||
useEffect(() => {
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
}, [attachmentFiles]);
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const f of attachmentsRef.current) {
|
||||
@@ -365,14 +346,9 @@ export const PromptInputProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component Context & Hooks
|
||||
// ============================================================================
|
||||
|
||||
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
||||
|
||||
export const usePromptInputAttachments = () => {
|
||||
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
|
||||
const provider = useOptionalProviderAttachments();
|
||||
const local = useContext(LocalAttachmentsContext);
|
||||
const context = local ?? provider;
|
||||
@@ -384,10 +360,6 @@ export const usePromptInputAttachments = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Referenced Sources (Local to PromptInput)
|
||||
// ============================================================================
|
||||
|
||||
export interface ReferencedSourcesContext {
|
||||
sources: (SourceDocumentUIPart & { id: string })[];
|
||||
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
|
||||
@@ -490,16 +462,11 @@ export type PromptInputProps = Omit<
|
||||
HTMLAttributes<HTMLFormElement>,
|
||||
"onSubmit" | "onError"
|
||||
> & {
|
||||
// e.g., "image/*" or leave undefined for any
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
// When true, accepts drops anywhere on document. Default false (opt-in).
|
||||
globalDrop?: boolean;
|
||||
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
||||
syncHiddenInput?: boolean;
|
||||
// Minimal constraints
|
||||
maxFiles?: number;
|
||||
// bytes
|
||||
maxFileSize?: number;
|
||||
onError?: (err: {
|
||||
code: "max_files" | "max_file_size" | "accept";
|
||||
@@ -524,24 +491,19 @@ export const PromptInput = ({
|
||||
children,
|
||||
...props
|
||||
}: PromptInputProps) => {
|
||||
// Try to use a provider controller if present
|
||||
const controller = useOptionalPromptInputController();
|
||||
const usingProvider = !!controller;
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
// ----- Local attachments (only used when no provider)
|
||||
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
||||
const files = usingProvider ? controller.attachments.files : items;
|
||||
|
||||
// ----- Local referenced sources (always local to PromptInput)
|
||||
const [referencedSources, setReferencedSources] = useState<
|
||||
(SourceDocumentUIPart & { id: string })[]
|
||||
>([]);
|
||||
|
||||
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
||||
const filesRef = useRef(files);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -565,7 +527,6 @@ export const PromptInput = ({
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.endsWith("/*")) {
|
||||
// e.g: image/* -> image/
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return f.type.startsWith(prefix);
|
||||
}
|
||||
@@ -638,7 +599,6 @@ export const PromptInput = ({
|
||||
[]
|
||||
);
|
||||
|
||||
// Wrapper that validates files before calling provider's add
|
||||
const addWithProviderValidation = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = [...fileList];
|
||||
@@ -713,7 +673,6 @@ export const PromptInput = ({
|
||||
clearReferencedSources();
|
||||
}, [clearAttachments, clearReferencedSources]);
|
||||
|
||||
// Let provider know about our hidden file input so external menus can call openFileDialog()
|
||||
useEffect(() => {
|
||||
if (!usingProvider) {
|
||||
return;
|
||||
@@ -721,22 +680,19 @@ export const PromptInput = ({
|
||||
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
|
||||
}, [usingProvider, controller]);
|
||||
|
||||
// Note: File input cannot be programmatically set for security reasons
|
||||
// The syncHiddenInput prop is no longer functional
|
||||
// Browsers forbid programmatically populating file inputs.
|
||||
useEffect(() => {
|
||||
if (syncHiddenInput && inputRef.current && files.length === 0) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [files, syncHiddenInput]);
|
||||
|
||||
// Attach drop handlers on nearest form and document (opt-in)
|
||||
useEffect(() => {
|
||||
const form = formRef.current;
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
if (globalDrop) {
|
||||
// when global drop is on, let the document-level handler own drops
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -805,7 +761,6 @@ export const PromptInput = ({
|
||||
if (event.currentTarget.files) {
|
||||
add(event.currentTarget.files);
|
||||
}
|
||||
// Reset input value to allow selecting files that were previously removed
|
||||
event.currentTarget.value = "";
|
||||
},
|
||||
[add]
|
||||
@@ -853,19 +808,16 @@ export const PromptInput = ({
|
||||
return (formData.get("message") as string) || "";
|
||||
})();
|
||||
|
||||
// Reset form immediately after capturing text to avoid race condition
|
||||
// where user input during async blob conversion would be lost
|
||||
// Reset before async conversion so new input is not cleared afterward.
|
||||
if (!usingProvider) {
|
||||
form.reset();
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert blob URLs to data URLs asynchronously
|
||||
const convertedFiles: FileUIPart[] = await Promise.all(
|
||||
files.map(async ({ id: _id, ...item }) => {
|
||||
if (item.url?.startsWith("blob:")) {
|
||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||
// If conversion failed, keep the original blob URL
|
||||
return {
|
||||
...item,
|
||||
url: dataUrl ?? item.url,
|
||||
@@ -877,7 +829,6 @@ export const PromptInput = ({
|
||||
|
||||
const result = onSubmit({ files: convertedFiles, text }, event);
|
||||
|
||||
// Handle both sync and async onSubmit
|
||||
if (result instanceof Promise) {
|
||||
try {
|
||||
await result;
|
||||
@@ -886,23 +837,21 @@ export const PromptInput = ({
|
||||
controller.textInput.clear();
|
||||
}
|
||||
} catch {
|
||||
// Don't clear on error - user may want to retry
|
||||
// Preserve failed submissions for retry.
|
||||
}
|
||||
} else {
|
||||
// Sync function completed without throwing, clear inputs
|
||||
clear();
|
||||
if (usingProvider) {
|
||||
controller.textInput.clear();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Don't clear on error - user may want to retry
|
||||
// Preserve failed submissions for retry.
|
||||
}
|
||||
},
|
||||
[usingProvider, controller, files, onSubmit, clear]
|
||||
);
|
||||
|
||||
// Render with or without local provider
|
||||
const inner = (
|
||||
<>
|
||||
<input
|
||||
@@ -932,7 +881,6 @@ export const PromptInput = ({
|
||||
</LocalReferencedSourcesContext.Provider>
|
||||
);
|
||||
|
||||
// Always provide LocalAttachmentsContext so children get validated add function
|
||||
return (
|
||||
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
|
||||
{withReferencedSources}
|
||||
@@ -966,10 +914,8 @@ export const PromptInputTextarea = ({
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(e) => {
|
||||
// Call the external onKeyDown handler first
|
||||
onKeyDown?.(e);
|
||||
|
||||
// If the external handler prevented default, don't run internal logic
|
||||
if (e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
@@ -983,7 +929,6 @@ export const PromptInputTextarea = ({
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
// Check if the submit button is disabled before submitting
|
||||
const { form } = e.currentTarget;
|
||||
const submitButton = form?.querySelector(
|
||||
'button[type="submit"]'
|
||||
@@ -995,7 +940,6 @@ export const PromptInputTextarea = ({
|
||||
form?.requestSubmit();
|
||||
}
|
||||
|
||||
// Remove last attachment when Backspace is pressed and textarea is empty
|
||||
if (
|
||||
e.key === "Backspace" &&
|
||||
e.currentTarget.value === "" &&
|
||||
@@ -1205,9 +1149,6 @@ export const PromptInputActionMenuItem = ({
|
||||
<DropdownMenuItem className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
// Note: Actions that perform side-effects (like opening a file dialog)
|
||||
// are provided in opt-in modules (e.g., prompt-input-attachments).
|
||||
|
||||
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
||||
status?: ChatStatus;
|
||||
onStop?: () => void;
|
||||
@@ -1364,7 +1305,6 @@ export const PromptInputTabLabel = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputTabLabelProps) => (
|
||||
// Content provided via children in props
|
||||
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
|
||||
<h3
|
||||
className={cn(
|
||||
|
||||
@@ -63,7 +63,6 @@ export const Reasoning = memo(
|
||||
...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>({
|
||||
@@ -80,7 +79,6 @@ export const Reasoning = memo(
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true;
|
||||
@@ -93,14 +91,12 @@ export const Reasoning = memo(
|
||||
}
|
||||
}, [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 &&
|
||||
|
||||
@@ -8,7 +8,6 @@ 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>
|
||||
|
||||
@@ -2,15 +2,10 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
+18
-99
@@ -1,132 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Editor, outlinePlugin, type Data } from '@reacteditor/core';
|
||||
import createTailwindCdnPlugin from '@reacteditor/plugin-tailwind-cdn';
|
||||
import { createShopifyPlugin } from '@reacteditor/plugin-shopify';
|
||||
import { appConfig } from '@/editor.config';
|
||||
import { ROUTE_KEYS, editorHref, findPageRoute } from '@/lib/pages';
|
||||
import { publishPage } from '@/lib/publish-page';
|
||||
import globals from '@/app.globals.json';
|
||||
|
||||
// Plugin instances must keep a stable identity across renders, same as
|
||||
// `appConfig`, so they are built once at module scope.
|
||||
//
|
||||
// The Tailwind CDN plugin only styles the editor's preview iframe; the public
|
||||
// routes still get their utilities from the compiled `app/globals.css`.
|
||||
const tailwindCdn = createTailwindCdnPlugin();
|
||||
|
||||
// Registers the `shopifyProduct` and `shopifyCollection` field types used by
|
||||
// the commerce blocks. Credentials are the same public storefront pair the
|
||||
// rendered components read — safe in the browser by definition.
|
||||
const shopify = createShopifyPlugin({
|
||||
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop',
|
||||
publicAccessToken:
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined,
|
||||
apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07',
|
||||
});
|
||||
|
||||
// Adds the outline panel — the tree of blocks on the page, for selecting and
|
||||
// reordering without hunting through the preview.
|
||||
const outline = outlinePlugin();
|
||||
|
||||
const plugins = [outline, tailwindCdn, shopify];
|
||||
const plugins = [
|
||||
outlinePlugin(),
|
||||
createTailwindCdnPlugin(),
|
||||
createShopifyPlugin({
|
||||
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? 'mock.shop',
|
||||
publicAccessToken:
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN ?? undefined,
|
||||
apiVersion: process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION ?? '2026-07',
|
||||
}),
|
||||
];
|
||||
|
||||
export interface PageEditorProps {
|
||||
/** Route key from `lib/pages.ts`, e.g. `/products/[handle]`. */
|
||||
routeKey: string;
|
||||
/**
|
||||
* The route's own `page.json`, imported by the `editor/page.tsx` that mounts
|
||||
* this — `import pageData from '../page.json'`. Bundled at build time, so the
|
||||
* editor opens with the page already in hand and no request to wait on.
|
||||
*/
|
||||
pagePath: string;
|
||||
page: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared editor shell, mounted by each route's `editor/page.tsx` child. The
|
||||
* public routes mount `PageRender` against the same `appConfig`, so the two
|
||||
* never drift.
|
||||
*
|
||||
* Being a child of the route it edits is what makes the preview real: the
|
||||
* editor sits on the same dynamic segments as the public page, so a block
|
||||
* calling `useParams()` inside the preview iframe sees the actual handle from
|
||||
* `/products/warrior-club-hoodie/editor` — no stand-in data required.
|
||||
*
|
||||
* Data comes in as a prop — the route's own `page.json`, imported by the
|
||||
* `editor/page.tsx` above it — and publishing writes that same file back to
|
||||
* disk through the `publishPage` server action.
|
||||
*/
|
||||
export default function PageEditor({ routeKey, page }: PageEditorProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
// `app.globals.json` holds the props of blocks marked `global: true` (header,
|
||||
// footer), which every page.json references via `"synced": true`. The editor
|
||||
// needs them alongside the page so those blocks render their real content.
|
||||
export default function PageEditor({ pagePath, page }: PageEditorProps) {
|
||||
const data = useMemo(
|
||||
() => ({ ...page, globals }) as unknown as Data,
|
||||
[page]
|
||||
);
|
||||
|
||||
// The editor lives at `<public path>/editor`; drop that segment to recover
|
||||
// the page's own URL for the route descriptor and the URL bar.
|
||||
const publicPath = useMemo(
|
||||
() => (pathname ?? '/editor').replace(/\/editor\/?$/, '') || '/',
|
||||
[pathname]
|
||||
);
|
||||
|
||||
const routeParams = useMemo(() => {
|
||||
const entries: Record<string, string | undefined> = {};
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (typeof value === 'string') entries[key] = value;
|
||||
}
|
||||
return entries;
|
||||
}, [params]);
|
||||
|
||||
const handlePublish = useCallback(
|
||||
async (published: Data) => {
|
||||
setStatus('Saving…');
|
||||
|
||||
try {
|
||||
const result = await publishPage(routeKey, published);
|
||||
|
||||
setStatus(
|
||||
result.error ? result.error : `Saved to ${result.file}`
|
||||
);
|
||||
} catch {
|
||||
setStatus('Could not reach the server.');
|
||||
}
|
||||
const result = await publishPage(pagePath, published);
|
||||
if (result.error) throw new Error(result.error);
|
||||
},
|
||||
[routeKey]
|
||||
[pagePath]
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
key={routeKey}
|
||||
theme="light"
|
||||
color="teal"
|
||||
config={appConfig as any}
|
||||
data={data}
|
||||
plugins={plugins}
|
||||
routes={ROUTE_KEYS}
|
||||
route={{ key: routeKey, path: publicPath, params: routeParams }}
|
||||
onRouteChange={(nextKey) => {
|
||||
// The picker hands back a route key; ignore anything not registered.
|
||||
if (findPageRoute(nextKey)) router.push(editorHref(nextKey));
|
||||
}}
|
||||
onPublish={handlePublish}
|
||||
headerTitle={findPageRoute(routeKey)?.label ?? routeKey}
|
||||
// The concrete URL being previewed. The editor's own URL bar shows the
|
||||
// route key (the template being edited); this is the resolved path.
|
||||
headerPath={publicPath}
|
||||
renderHeaderActions={({ state }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status ?? `${state.data.content.length} blocks`}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,20 +10,6 @@ export type PageData = {
|
||||
globals?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared renderer for a route. Drop a `page.json` next to a route's `page.tsx`,
|
||||
* import it, and hand it here:
|
||||
*
|
||||
* import page from "./page.json";
|
||||
* export default () => <PageRender page={page} />;
|
||||
*
|
||||
* The same `appConfig` backs `PageEditor`, so what an editor sees is what the
|
||||
* route ships.
|
||||
*
|
||||
* `app.globals.json` carries the props of blocks marked `global: true` — the
|
||||
* header and footer. Every page.json references them via `"synced": true`, so
|
||||
* editing the header once updates all thirteen routes.
|
||||
*/
|
||||
export default function PageRender({ page }: { page: PageData }) {
|
||||
const data = { root: page.root, content: page.content, globals };
|
||||
return <Render config={appConfig as any} data={data as any} />;
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from '@/components/ui/empty';
|
||||
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
|
||||
import { isDefaultTitleSelection } from '@/services/shopify/shop';
|
||||
|
||||
const CartDrawer: React.FC = () => {
|
||||
const isOpen = useCartStore((s) => s.isOpen);
|
||||
@@ -40,8 +40,7 @@ const CartDrawer: React.FC = () => {
|
||||
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.
|
||||
// Track row-level work separately from the cart's global loading state.
|
||||
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
|
||||
|
||||
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
|
||||
@@ -80,7 +79,6 @@ const CartDrawer: React.FC = () => {
|
||||
setDiscountError(null);
|
||||
const updatedCart = await applyDiscountCode(code);
|
||||
|
||||
// Shopify accepts unknown codes silently, flagging them as inapplicable.
|
||||
const accepted = updatedCart.discountCodes?.some(
|
||||
(discount) =>
|
||||
discount.applicable &&
|
||||
@@ -104,7 +102,6 @@ const CartDrawer: React.FC = () => {
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
@@ -119,7 +116,6 @@ const CartDrawer: React.FC = () => {
|
||||
<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">
|
||||
@@ -140,7 +136,6 @@ const CartDrawer: React.FC = () => {
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Cart Items */}
|
||||
<SheetBody className="px-5">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -169,7 +164,6 @@ const CartDrawer: React.FC = () => {
|
||||
|
||||
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 ? (
|
||||
<Image
|
||||
@@ -186,7 +180,6 @@ const CartDrawer: React.FC = () => {
|
||||
)}
|
||||
</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">
|
||||
@@ -209,7 +202,6 @@ const CartDrawer: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center gap-x-2 mt-2">
|
||||
<div className="inline-flex items-center rounded-full bg-secondary">
|
||||
<Button
|
||||
@@ -274,10 +266,8 @@ const CartDrawer: React.FC = () => {
|
||||
)}
|
||||
</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"
|
||||
@@ -309,7 +299,6 @@ const CartDrawer: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Estimated total */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-base text-foreground">
|
||||
|
||||
@@ -25,7 +25,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
href={`/collections/${collection.handle}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
{/* Collection Image */}
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
{collection.image ? (
|
||||
<Image
|
||||
@@ -42,7 +41,6 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
)}
|
||||
</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}
|
||||
|
||||
@@ -8,11 +8,6 @@ export type CollectionDetailBlockProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used two ways: dropped on `/collections/[handle]` it follows the route, so
|
||||
* `collection` stays empty and the picker only drives the editor preview;
|
||||
* dropped on any other page it pins to whichever collection is picked.
|
||||
*/
|
||||
const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
|
||||
label: 'Collection page',
|
||||
icon: <Boxes size={16} />,
|
||||
|
||||
@@ -33,12 +33,7 @@ const SORT_OPTIONS: SortOption[] = [
|
||||
];
|
||||
|
||||
interface CollectionDetailProps {
|
||||
/**
|
||||
* Pins the block to one collection. Left empty, it reads the `[handle]`
|
||||
* segment instead, which is what the `/collections/[handle]` template does.
|
||||
*/
|
||||
handle?: string;
|
||||
/** Overrides the collection's own title. Empty falls back to Shopify's. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -65,15 +60,12 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
||||
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(() => {
|
||||
// Reached when the editor was opened on the literal `[handle]` pattern
|
||||
// rather than a real collection URL. Drop the skeleton and say so rather
|
||||
// than spinning forever.
|
||||
// The literal editor route pattern has no collection to load.
|
||||
if (!handle || handle === '[handle]') {
|
||||
setLoading(false);
|
||||
setError(
|
||||
@@ -106,8 +98,7 @@ const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
||||
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.
|
||||
// Do not remove active facet choices as result counts change.
|
||||
if (activeFilters.length === 0) setFilters(page.filters);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -6,21 +6,11 @@ import Image from 'next/image';
|
||||
export interface ContentSectionProps {
|
||||
eyebrow?: React.ReactNode;
|
||||
heading?: React.ReactNode;
|
||||
/**
|
||||
* Plain text when it comes from a `page.json`; while the field is being
|
||||
* edited inline the editor hands over a ReactNode instead, so this must not
|
||||
* assume a string.
|
||||
*/
|
||||
body?: React.ReactNode;
|
||||
imageUrl?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic prose section for the non-commerce routes (about, landing copy).
|
||||
* Typography matches the storefront's other sections — the editor supplies the
|
||||
* words and the image, never the layout.
|
||||
*/
|
||||
const ContentSection: React.FC<ContentSectionProps> = ({
|
||||
eyebrow,
|
||||
heading,
|
||||
@@ -48,8 +38,6 @@ const ContentSection: React.FC<ContentSectionProps> = ({
|
||||
<div className="relative mt-10 aspect-[3/2] overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
// `heading` is a node while it's edited inline, and alt text
|
||||
// has to be a string — fall back to empty rather than stringify.
|
||||
alt={imageAlt || (typeof heading === 'string' ? heading : '')}
|
||||
fill
|
||||
sizes="(min-width: 768px) 42rem, 100vw"
|
||||
@@ -61,14 +49,11 @@ const ContentSection: React.FC<ContentSectionProps> = ({
|
||||
{body && (
|
||||
<div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground">
|
||||
{typeof body === 'string'
|
||||
? // Authored as plain paragraphs; blank lines separate them.
|
||||
body
|
||||
? body
|
||||
.split(/\n{2,}/)
|
||||
.filter((paragraph) => paragraph.trim())
|
||||
.map((paragraph, index) => <p key={index}>{paragraph}</p>)
|
||||
: // Mid-edit: render the editor's node as-is so inline editing
|
||||
// keeps working. Paragraph splitting resumes once saved.
|
||||
body}
|
||||
: body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,9 +45,6 @@ const Footer: React.FC<FooterProps> = ({
|
||||
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}
|
||||
|
||||
@@ -11,16 +11,10 @@ export type HeaderBlockProps = {
|
||||
links?: Array<{ label: string; url: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content only: the store name, the logo, the announcement copy and the nav
|
||||
* labels. Stickiness, backdrop blur, spacing and the cart/search/account
|
||||
* affordances are the component's business, not the editor's.
|
||||
*/
|
||||
const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
||||
label: 'Header',
|
||||
icon: <Menu size={16} />,
|
||||
category: 'navigation',
|
||||
// Shared across pages: edit it once and every page.json picks it up.
|
||||
global: true,
|
||||
defaultProps: {
|
||||
storeName: 'Shop',
|
||||
@@ -47,9 +41,7 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
||||
announcement: {
|
||||
label: 'Announcement',
|
||||
type: 'text',
|
||||
// Deliberately not `contentEditable`: an inline-edited field arrives as a
|
||||
// ReactNode, which stays truthy once emptied, so the bar would linger as
|
||||
// an empty band. Keeping it a plain string makes "blank hides it" work.
|
||||
// Keep this a string so blank copy can hide the announcement.
|
||||
placeholder: 'Leave empty to hide the bar',
|
||||
},
|
||||
announcementUrl: { label: 'Announcement link', type: 'text' },
|
||||
@@ -76,9 +68,6 @@ const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
||||
storeName={storeName}
|
||||
logoUrl={logoUrl}
|
||||
links={links}
|
||||
// Header decides visibility: blank copy hides the bar, and the toggle
|
||||
// hides it regardless — needed because an inline-edited field is a node,
|
||||
// which can't be inspected for emptiness.
|
||||
showAnnouncement={showAnnouncement}
|
||||
announcement={announcement}
|
||||
announcementUrl={announcementUrl}
|
||||
|
||||
@@ -43,10 +43,8 @@ interface HeaderProps {
|
||||
storeName?: string;
|
||||
logoUrl?: string;
|
||||
links?: NavLink[];
|
||||
/** Thin bar above the nav. Blank copy hides it. */
|
||||
announcement?: React.ReactNode;
|
||||
announcementUrl?: string;
|
||||
/** Hides the bar outright, whatever the copy says. */
|
||||
showAnnouncement?: 'yes' | 'no';
|
||||
}
|
||||
|
||||
@@ -63,9 +61,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
}) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Whitespace-only copy counts as empty. While the field is edited inline the
|
||||
// editor supplies a node rather than a string — it can't be inspected for
|
||||
// emptiness, which is what the explicit toggle is for.
|
||||
// Inline editing supplies a node, so only string values can be blank-checked.
|
||||
const hasAnnouncementText =
|
||||
typeof announcement === 'string'
|
||||
? announcement.trim() !== ''
|
||||
@@ -75,7 +71,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sits above the sticky nav, so it scrolls away on its own. */}
|
||||
{showAnnouncementBar && (
|
||||
<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">
|
||||
@@ -96,10 +91,8 @@ const Header: React.FC<HeaderProps> = ({
|
||||
<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) => (
|
||||
@@ -113,12 +106,10 @@ const Header: React.FC<HeaderProps> = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center">
|
||||
<SearchDialog />
|
||||
<CartIcon />
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<Button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
variant="ghost"
|
||||
@@ -136,7 +127,6 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</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">
|
||||
|
||||
@@ -18,7 +18,6 @@ const policyBodyEditor: ComponentConfig<PolicyBodyProps> = {
|
||||
handle: {
|
||||
label: 'Policy',
|
||||
type: 'select',
|
||||
// Empty follows the `[handle]` route segment on /policies/[handle].
|
||||
options: [
|
||||
{ label: 'Follow the page URL', value: '' },
|
||||
...POLICY_HANDLES.map((handle) => ({
|
||||
|
||||
@@ -5,20 +5,11 @@ import { useParams } from 'next/navigation';
|
||||
import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies';
|
||||
|
||||
export interface PolicyBodyProps {
|
||||
/**
|
||||
* Pins the block to one policy. Left empty, it reads the `[handle]` segment,
|
||||
* which is what the `/policies/[handle]` template does.
|
||||
*/
|
||||
handle?: string;
|
||||
/** Overrides the policy's own title. Empty falls back to Shopify's. */
|
||||
title?: string;
|
||||
notFoundMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy copy is authored in the Shopify admin, not here — the editor only
|
||||
* chooses which policy to show and can override the heading.
|
||||
*/
|
||||
const PolicyBody: React.FC<PolicyBodyProps> = ({
|
||||
handle: handleProp,
|
||||
title: titleProp,
|
||||
|
||||
@@ -66,7 +66,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
||||
href={`/products/${product.handle}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
{firstImage ? (
|
||||
<Image
|
||||
@@ -95,7 +94,6 @@ const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
||||
)}
|
||||
</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)}
|
||||
|
||||
@@ -8,11 +8,6 @@ export type ProductDetailBlockProps = {
|
||||
addToCartLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* On `/products/[handle]` leave `product` empty so the block follows the route;
|
||||
* the picker then only chooses what the editor previews. Pinning a product
|
||||
* turns it into a featured-product block usable on any page.
|
||||
*/
|
||||
const productDetailEditor: ComponentConfig<ProductDetailBlockProps> = {
|
||||
label: 'Product page',
|
||||
icon: <Tag size={16} />,
|
||||
|
||||
@@ -60,7 +60,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
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;
|
||||
@@ -78,9 +77,7 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
}
|
||||
}, [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.
|
||||
// Unselected options act as wildcards when checking variant availability.
|
||||
const isOptionValueAvailable = (optionName: string, value: string) => {
|
||||
const variants = product?.variants.edges ?? [];
|
||||
if (variants.length === 0) return true;
|
||||
@@ -100,7 +97,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
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
|
||||
@@ -126,8 +122,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -152,7 +146,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
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
|
||||
@@ -162,7 +155,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -26,8 +26,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
|
||||
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;
|
||||
@@ -47,8 +45,7 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
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.
|
||||
// Add pointer dragging without disrupting native touch scrolling.
|
||||
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -74,7 +71,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
if (!drag.current || !scroller) return;
|
||||
|
||||
drag.current = null;
|
||||
// Restoring snap lets the browser settle on the nearest slide.
|
||||
scroller.style.scrollSnapType = '';
|
||||
};
|
||||
|
||||
@@ -88,7 +84,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Close on Escape, and keep the page behind the overlay from scrolling.
|
||||
useEffect(() => {
|
||||
if (!isZoomed) return;
|
||||
|
||||
@@ -119,7 +114,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Swipeable carousel on mobile, grid from sm up */}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
@@ -152,7 +146,6 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
))}
|
||||
</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) => (
|
||||
@@ -182,15 +175,10 @@ const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
<Image
|
||||
src={zoomedImage.url}
|
||||
alt={zoomedImage.altText || 'Product image'}
|
||||
// Shopify gives us the intrinsic size; the square fallback only
|
||||
// reserves space until CSS scales it down to fit the overlay.
|
||||
width={zoomedImage.width || 1600}
|
||||
height={zoomedImage.height || 1600}
|
||||
sizes="100vw"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
// w/h-auto keeps the box at the image's own ratio; without it the
|
||||
// width+height attributes make both axes definite and the element
|
||||
// stretches to the overlay, swallowing backdrop clicks that close it.
|
||||
className="max-h-full max-w-full w-auto h-auto object-contain"
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ 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';
|
||||
import { isDefaultTitleOption } from '@/services/shopify/shop';
|
||||
|
||||
interface ProductPrice {
|
||||
amount: string;
|
||||
@@ -52,15 +52,12 @@ interface ProductDetailInfoProps {
|
||||
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 } => {
|
||||
@@ -103,13 +100,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
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.
|
||||
// Show swatches first and omit Shopify's synthetic single-SKU option.
|
||||
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
|
||||
@@ -131,7 +126,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Options — colour swatches lead, whatever order the API returns */}
|
||||
{orderedOptions.map((option) => {
|
||||
const isSwatch = isSwatchOption(option);
|
||||
const selected = selectedOptions[option.name];
|
||||
@@ -172,12 +166,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
: '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)'),
|
||||
@@ -215,7 +203,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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
|
||||
@@ -252,7 +239,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
|
||||
<ShopPayButton
|
||||
className="mt-3"
|
||||
variants={
|
||||
@@ -263,7 +249,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
onFallbackClick={handleBuyNow}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
{(product.descriptionHtml || product.description) && (
|
||||
<div className="mt-10 text-sm leading-6 text-foreground product-description">
|
||||
{product.descriptionHtml ? (
|
||||
|
||||
@@ -12,13 +12,10 @@ import {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,7 +34,6 @@ interface ProductFiltersProps {
|
||||
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');
|
||||
|
||||
@@ -55,7 +51,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
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"')
|
||||
);
|
||||
@@ -93,7 +88,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
onActiveFiltersChange([]);
|
||||
};
|
||||
|
||||
// Labels for the chips at the top of the panel.
|
||||
const activeChips = filters
|
||||
.flatMap((filter) => filter.values)
|
||||
.filter((value) => activeSet.has(value.input));
|
||||
@@ -118,7 +112,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
</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) => (
|
||||
@@ -147,7 +140,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{priceFilter && (
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-3 text-base text-foreground">Price</h3>
|
||||
@@ -183,7 +175,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Facets */}
|
||||
{listFilters.map((filter) => (
|
||||
<div key={filter.id} className="mb-8">
|
||||
<h3 className="mb-3 text-base text-foreground">
|
||||
@@ -209,9 +200,6 @@ const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
: '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)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -23,7 +23,6 @@ const productRecommendationsEditor: ComponentConfig<ProductRecommendationsBlockP
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
product: {
|
||||
label: 'Seed product',
|
||||
// Empty follows the `[handle]` route segment; Shopify picks the rest.
|
||||
type: 'shopifyProduct',
|
||||
} as any,
|
||||
limit: { label: 'Products shown', type: 'number', min: 2, max: 12 },
|
||||
|
||||
@@ -10,10 +10,6 @@ import ProductCard from './product-card';
|
||||
|
||||
interface ProductRecommendationsProps {
|
||||
productId?: string;
|
||||
/**
|
||||
* Seeds recommendations from a specific product. Left empty, it reads the
|
||||
* `[handle]` segment, which is what the `/products/[handle]` template does.
|
||||
*/
|
||||
handle?: string;
|
||||
title?: string;
|
||||
limit?: number;
|
||||
|
||||
@@ -21,8 +21,6 @@ interface ProductToolbarProps {
|
||||
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,
|
||||
@@ -69,7 +67,6 @@ const ProductToolbar: React.FC<ProductToolbarProps> = ({
|
||||
|
||||
{sortOpen && (
|
||||
<>
|
||||
{/* Click-away layer sits under the menu, above the page. */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSortOpen(false)}
|
||||
|
||||
@@ -10,10 +10,6 @@ export type ProductsBlockProps = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Column counts, gutters and the card design are fixed in `products.tsx`. The
|
||||
* editor picks *which* products appear and what the section says about them.
|
||||
*/
|
||||
const productsEditor: ComponentConfig<ProductsBlockProps> = {
|
||||
label: 'Product grid',
|
||||
icon: <LayoutGrid size={16} />,
|
||||
@@ -30,8 +26,6 @@ const productsEditor: ComponentConfig<ProductsBlockProps> = {
|
||||
subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true },
|
||||
collection: {
|
||||
label: 'Collection',
|
||||
// Registered by createShopifyPlugin — a live search against the store.
|
||||
// Leave empty to show the newest products across the whole catalogue.
|
||||
type: 'shopifyCollection',
|
||||
} as any,
|
||||
limit: { label: 'Products shown', type: 'number', min: 2, max: 48 },
|
||||
|
||||
@@ -52,10 +52,6 @@ interface ProductsProps {
|
||||
subtitle?: string;
|
||||
limit?: number;
|
||||
showLoadMore?: boolean;
|
||||
/**
|
||||
* Narrows the grid to one collection. Empty shows the newest products across
|
||||
* the whole catalogue, which is what the home page does.
|
||||
*/
|
||||
collectionHandle?: string;
|
||||
}
|
||||
|
||||
@@ -73,8 +69,6 @@ const Products: React.FC<ProductsProps> = ({
|
||||
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) {
|
||||
@@ -84,8 +78,6 @@ const Products: React.FC<ProductsProps> = ({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// A pinned collection uses the collection query so its own ordering
|
||||
// applies; otherwise fall back to newest-first across the catalogue.
|
||||
const page = collectionHandle
|
||||
? await getCollectionProductsPage(collectionHandle, {
|
||||
first: limit,
|
||||
|
||||
@@ -6,10 +6,6 @@ export type SearchResultsBlockProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result set comes from the `?q=` param and the shopper's own filter and
|
||||
* sort choices, so the heading is the only thing left for the editor to own.
|
||||
*/
|
||||
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
||||
label: 'Search results',
|
||||
icon: <Search size={16} />,
|
||||
|
||||
@@ -50,7 +50,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
const sort = SORT_OPTIONS[sortIndex];
|
||||
// Serialised so the effect re-runs when the selection changes, not the array.
|
||||
// Stabilize the effect dependency by filter contents.
|
||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,8 +62,6 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
||||
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,
|
||||
@@ -77,8 +75,7 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
||||
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.
|
||||
// Do not remove active facet choices as result counts change.
|
||||
if (activeFilters.length === 0) setFilters(result.filters);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
@@ -140,7 +137,6 @@ const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -8,9 +8,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -22,7 +20,6 @@ const ShopMenu: React.FC<ShopMenuProps> = ({
|
||||
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);
|
||||
@@ -138,7 +135,6 @@ const ShopMenu: React.FC<ShopMenuProps> = ({
|
||||
|
||||
{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}
|
||||
|
||||
@@ -16,11 +16,10 @@ interface ShopPayButtonProps {
|
||||
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.
|
||||
// Cart permalinks require numeric IDs rather than Storefront GIDs.
|
||||
function toNumericVariantId(id: string): string | null {
|
||||
const trimmed = id.trim();
|
||||
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
|
||||
@@ -38,8 +37,6 @@ function toStoreUrl(domain?: string): string | 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;
|
||||
@@ -72,8 +69,6 @@ const ShopPayButton: React.FC<ShopPayButtonProps> = ({
|
||||
</>
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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]',
|
||||
}) => (
|
||||
|
||||
@@ -4,11 +4,6 @@ import StoreAssistant from '@/components/shopify/store-assistant';
|
||||
|
||||
export type StoreAssistantBlockProps = Record<string, never>;
|
||||
|
||||
/**
|
||||
* The floating AI shopping assistant. It has no editable copy — its prompts and
|
||||
* suggestions come from `/api/chat` — so it is registered purely so a page can
|
||||
* choose whether to carry it.
|
||||
*/
|
||||
const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = {
|
||||
label: 'Store assistant',
|
||||
icon: <MessageCircle size={16} />,
|
||||
|
||||
@@ -64,8 +64,6 @@ import {
|
||||
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 = [
|
||||
@@ -101,8 +99,6 @@ const toolName = (type: string) =>
|
||||
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
|
||||
@@ -166,7 +162,6 @@ const summariseTool = (
|
||||
}
|
||||
};
|
||||
|
||||
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
|
||||
const isInternalLink = (url: string) => {
|
||||
if (url.startsWith('/')) return true;
|
||||
try {
|
||||
@@ -229,7 +224,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
||||
<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 />
|
||||
@@ -270,7 +264,6 @@ const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
||||
|
||||
AttachmentItem.displayName = 'AttachmentItem';
|
||||
|
||||
// Pending uploads, shown inline above the textarea.
|
||||
const PromptInputAttachmentsDisplay = () => {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
@@ -295,15 +288,12 @@ const PromptInputAttachmentsDisplay = () => {
|
||||
};
|
||||
|
||||
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({
|
||||
@@ -323,7 +313,6 @@ const StoreAssistant: React.FC = () => {
|
||||
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 ?? [];
|
||||
@@ -335,7 +324,6 @@ const StoreAssistant: React.FC = () => {
|
||||
|
||||
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 ${
|
||||
@@ -365,8 +353,6 @@ const StoreAssistant: React.FC = () => {
|
||||
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
|
||||
@@ -393,8 +379,6 @@ const StoreAssistant: React.FC = () => {
|
||||
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'
|
||||
}
|
||||
@@ -432,8 +416,6 @@ const StoreAssistant: React.FC = () => {
|
||||
};
|
||||
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'
|
||||
@@ -472,7 +454,6 @@ const StoreAssistant: React.FC = () => {
|
||||
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) => (
|
||||
@@ -544,9 +525,6 @@ const StoreAssistant: React.FC = () => {
|
||||
</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)}
|
||||
|
||||
@@ -36,8 +36,6 @@ function CommandDialog({
|
||||
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> & {
|
||||
@@ -56,7 +54,6 @@ function CommandDialog({
|
||||
<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
|
||||
|
||||
@@ -17,16 +17,13 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
"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
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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'
|
||||
@@ -27,7 +25,6 @@ function getItemVariants(
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
// Utility function to combine classNames
|
||||
function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ const RemixIcon: React.FC<RemixIconProps> = ({
|
||||
}) => {
|
||||
if (!name) return null;
|
||||
|
||||
// Normalise: accept "RiTruckLine", "ri-truck-line", or "riTruckLine"
|
||||
// Accept component, kebab-case, or camelCase icon names.
|
||||
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 = (
|
||||
|
||||
Reference in New Issue
Block a user