Migrate to vite
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Shopify (defaults to mock.shop if unset)
|
# Shopify (defaults to mock.shop if unset)
|
||||||
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
|
VITE_SHOPIFY_DOMAIN=mock.shop
|
||||||
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||||
|
|
||||||
# AI Gateway (Vercel) — used by /api/chat. Required for the AI panel.
|
# Anthropic — used by /api/chat. Required for the AI panel.
|
||||||
AI_GATEWAY_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,8 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
next-env.d.ts
|
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,15 +1,15 @@
|
|||||||
# React Editor Demo (Shopify)
|
# React Editor Demo (Shopify)
|
||||||
|
|
||||||
Standalone Next.js 16 (app router) demo wiring up the Shopify-aware React Editor.
|
Standalone Vite SPA wiring up the Shopify-aware React Editor via `<App />` from `@reacteditor/core`.
|
||||||
|
|
||||||
## What's here
|
## What's here
|
||||||
|
|
||||||
|
- **`index.html`** — Vite entrypoint.
|
||||||
|
- **`src/main.tsx`** — mounts `<App />` into `#root`.
|
||||||
|
- **`src/App.tsx`** — wires `<App />` from `@reacteditor/core` with the schema, plugins, and the Shopify provider.
|
||||||
- **`editor/`** — drop-in copy of the editor scaffold from `fe-shopify-client/app/editor`. Components, config, theme, Shopify client/queries/hooks/contexts, plugin-ai vendor css.
|
- **`editor/`** — drop-in copy of the editor scaffold from `fe-shopify-client/app/editor`. Components, config, theme, Shopify client/queries/hooks/contexts, plugin-ai vendor css.
|
||||||
- **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`. The save endpoint writes back to this file.
|
- **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`.
|
||||||
- **`app/[[...path]]/page.tsx`** — view route. Reads the schema and mounts `<Render>`.
|
- **`api/chat.ts`** — `aiPlugin` endpoint exposed in dev via a Vite middleware. Talks to Claude via `@ai-sdk/anthropic` with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.).
|
||||||
- **`app/edit/[[...path]]/page.tsx`** — edit route. Reads the schema and mounts `<Editor>`.
|
|
||||||
- **`app/api/save-schema/route.ts`** — POST `{ route, data }` → patches `app.schema.json` on disk.
|
|
||||||
- **`app/api/chat/route.ts`** — `aiPlugin` endpoint. Uses the Vercel AI Gateway (`AI_GATEWAY_API_KEY`) to talk to Claude, with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.).
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -19,14 +19,10 @@ yarn install
|
|||||||
yarn dev # → http://localhost:3001
|
yarn dev # → http://localhost:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
- View any route: `http://localhost:3001/`, `/about`, `/products/handle`, etc.
|
|
||||||
- Edit any route: `http://localhost:3001/edit`, `/edit/about`, etc.
|
|
||||||
- 404 in view mode means the route isn't in `app.schema.json` yet — add it via the editor's **New page** button.
|
|
||||||
|
|
||||||
## Shopify
|
## Shopify
|
||||||
|
|
||||||
Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `NEXT_PUBLIC_SHOPIFY_DOMAIN` and `NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`.
|
Defaults to the public `mock.shop` storefront (no token needed) so commerce blocks render demo data immediately. Override via `VITE_SHOPIFY_DOMAIN` and `VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN` in `.env.local`.
|
||||||
|
|
||||||
## AI
|
## AI
|
||||||
|
|
||||||
`/api/chat` mirrors the host app's pattern — pass a string-form model id (`anthropic/claude-sonnet-4.6`) and the AI SDK routes through the Vercel AI Gateway using `AI_GATEWAY_API_KEY`. Tools include the React Editor built-ins plus Shopify search/lookup helpers.
|
`/api/chat` is served in dev by a Vite middleware that loads `api/chat.ts` via `ssrLoadModule`. Set `ANTHROPIC_API_KEY` in `.env.local`. Tools include the React Editor built-ins plus Shopify search/lookup helpers. For production deployment you'll need to host `api/chat.ts` behind your own Node server (the handler exports a standard `POST(req: Request): Promise<Response>`).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
tool,
|
tool,
|
||||||
type UIMessage,
|
type UIMessage,
|
||||||
} from "ai";
|
} from "ai";
|
||||||
|
import { anthropic } from "@ai-sdk/anthropic";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
reactEditorTools,
|
reactEditorTools,
|
||||||
@@ -20,9 +21,6 @@ import {
|
|||||||
GET_COLLECTION_PRODUCTS_QUERY,
|
GET_COLLECTION_PRODUCTS_QUERY,
|
||||||
} from "@/editor/graphql/collections";
|
} from "@/editor/graphql/collections";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const maxDuration = 60;
|
|
||||||
|
|
||||||
type Body = {
|
type Body = {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
editorContext?: Parameters<typeof getEditorContext>[0];
|
editorContext?: Parameters<typeof getEditorContext>[0];
|
||||||
@@ -48,11 +46,11 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
domain:
|
domain:
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ??
|
process.env.VITE_SHOPIFY_DOMAIN ??
|
||||||
process.env.SHOPIFY_DOMAIN ??
|
process.env.SHOPIFY_DOMAIN ??
|
||||||
"mock.shop",
|
"mock.shop",
|
||||||
token:
|
token:
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
process.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||||
process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||||
"",
|
"",
|
||||||
};
|
};
|
||||||
@@ -211,7 +209,7 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: "anthropic/claude-sonnet-4.6",
|
model: anthropic("claude-sonnet-4-5"),
|
||||||
system: getEditorContext(editorContext),
|
system: getEditorContext(editorContext),
|
||||||
messages: await convertToModelMessages(messages),
|
messages: await convertToModelMessages(messages),
|
||||||
tools: {
|
tools: {
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { readSchema } from "@/lib/schema.server";
|
|
||||||
import AppClient from "@/components/AppClient";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function Page({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ path?: string[] }>;
|
|
||||||
}) {
|
|
||||||
const { path } = await params;
|
|
||||||
const segments = (path ?? []).filter(Boolean);
|
|
||||||
const currentPath = segments.length === 0 ? "/" : "/" + segments.join("/");
|
|
||||||
const pages = await readSchema();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: "100vh", width: "100vw" }}>
|
|
||||||
<AppClient pages={pages} currentPath={currentPath} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "React Editor — Shopify Demo",
|
|
||||||
description: "Standalone Next.js demo wiring the Shopify-aware React Editor.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
||||||
</head>
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
App,
|
|
||||||
blocksPlugin,
|
|
||||||
outlinePlugin,
|
|
||||||
} from "@reacteditor/core";
|
|
||||||
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
|
|
||||||
import { aiPlugin } from "@reacteditor/plugin-ai";
|
|
||||||
import "@reacteditor/core/dist/index.css";
|
|
||||||
import "@reacteditor/plugin-ai/styles.css";
|
|
||||||
import { createConfig } from "@/editor/config";
|
|
||||||
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
|
|
||||||
|
|
||||||
type Pages = Record<string, { root: any; content: any[] }>;
|
|
||||||
|
|
||||||
const SHOPIFY_DOMAIN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
|
|
||||||
const STOREFRONT_TOKEN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
|
||||||
|
|
||||||
export default function AppClient({
|
|
||||||
pages,
|
|
||||||
currentPath,
|
|
||||||
}: {
|
|
||||||
pages: Pages;
|
|
||||||
currentPath: string;
|
|
||||||
}) {
|
|
||||||
const config = useMemo(
|
|
||||||
() =>
|
|
||||||
createConfig({
|
|
||||||
domain: SHOPIFY_DOMAIN,
|
|
||||||
token: STOREFRONT_TOKEN || null,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const plugins = useMemo(
|
|
||||||
() => [
|
|
||||||
createTailwindCdnPlugin(),
|
|
||||||
aiPlugin({
|
|
||||||
api: "/api/chat",
|
|
||||||
attachments: true,
|
|
||||||
body: { route: currentPath },
|
|
||||||
}),
|
|
||||||
blocksPlugin(),
|
|
||||||
outlinePlugin(),
|
|
||||||
],
|
|
||||||
[currentPath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePublish = useCallback(
|
|
||||||
(data: any, route?: string) => {
|
|
||||||
const path = route ?? currentPath;
|
|
||||||
if (typeof window !== "undefined" && window.parent !== window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{ type: "PUBLISH", path, data },
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentPath],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
|
|
||||||
<App
|
|
||||||
config={config as any}
|
|
||||||
pages={pages as any}
|
|
||||||
currentPath={currentPath}
|
|
||||||
plugins={plugins}
|
|
||||||
iframe={{ enabled: true }}
|
|
||||||
onPublish={handlePublish}
|
|
||||||
/>
|
|
||||||
</ShopifyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Editor,
|
|
||||||
blocksPlugin,
|
|
||||||
outlinePlugin,
|
|
||||||
useGetEditor,
|
|
||||||
type Route,
|
|
||||||
} from "@reacteditor/core";
|
|
||||||
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
|
|
||||||
import { aiPlugin } from "@reacteditor/plugin-ai";
|
|
||||||
import "@reacteditor/core/dist/index.css";
|
|
||||||
import "@reacteditor/plugin-ai/styles.css";
|
|
||||||
import { createConfig } from "@/editor/config";
|
|
||||||
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
|
|
||||||
import { Button } from "@/editor/components/ui/button";
|
|
||||||
import { resolveSchemaEntry, templateKeyForRoute } from "@/lib/schema-resolver";
|
|
||||||
|
|
||||||
type Schema = Record<string, { root: any; content: any[] }>;
|
|
||||||
|
|
||||||
const SHOPIFY_DOMAIN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
|
|
||||||
const STOREFRONT_TOKEN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
|
||||||
|
|
||||||
function SaveButton({
|
|
||||||
currentPath,
|
|
||||||
onSave,
|
|
||||||
}: {
|
|
||||||
currentPath: string;
|
|
||||||
onSave: (route: string, data: any) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const getEditor = useGetEditor();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
if (saving) return;
|
|
||||||
setSaving(true);
|
|
||||||
const data = (getEditor() as any)?.appState?.data;
|
|
||||||
if (data) await onSave(currentPath, data);
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
setSaving(false);
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditorClient({
|
|
||||||
initialSchema,
|
|
||||||
initialPath,
|
|
||||||
}: {
|
|
||||||
initialSchema: Schema;
|
|
||||||
initialPath: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [schema, setSchema] = useState<Schema>(initialSchema);
|
|
||||||
const [currentPath, setCurrentPath] = useState<string>(initialPath);
|
|
||||||
|
|
||||||
const editKey = useMemo(
|
|
||||||
() => templateKeyForRoute(currentPath) ?? currentPath,
|
|
||||||
[currentPath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = useMemo(
|
|
||||||
() =>
|
|
||||||
resolveSchemaEntry(schema, currentPath)?.data ?? {
|
|
||||||
root: { props: {} },
|
|
||||||
content: [],
|
|
||||||
},
|
|
||||||
[schema, currentPath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const routes: Route[] = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.entries(schema).map(([path, page]) => ({
|
|
||||||
path,
|
|
||||||
title:
|
|
||||||
(page?.root as any)?.props?.title ??
|
|
||||||
(path === "/" ? "Home" : path),
|
|
||||||
})),
|
|
||||||
[schema],
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = useMemo(
|
|
||||||
() =>
|
|
||||||
createConfig({
|
|
||||||
domain: SHOPIFY_DOMAIN,
|
|
||||||
token: STOREFRONT_TOKEN || null,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const persist = useCallback(async (route: string, next: any) => {
|
|
||||||
setSchema((s) => ({ ...s, [route]: next }));
|
|
||||||
if (typeof window !== "undefined" && window.parent !== window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{ type: "PUBLISH", path: route, data: next },
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const plugins = useMemo(
|
|
||||||
() => [
|
|
||||||
createTailwindCdnPlugin(),
|
|
||||||
aiPlugin({
|
|
||||||
api: "/api/chat",
|
|
||||||
attachments: true,
|
|
||||||
body: { route: currentPath },
|
|
||||||
}),
|
|
||||||
blocksPlugin(),
|
|
||||||
outlinePlugin(),
|
|
||||||
],
|
|
||||||
[currentPath],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
|
|
||||||
<Editor
|
|
||||||
key={currentPath}
|
|
||||||
config={config as any}
|
|
||||||
data={data as any}
|
|
||||||
plugins={plugins}
|
|
||||||
headerPath={currentPath}
|
|
||||||
routes={routes}
|
|
||||||
currentPath={currentPath}
|
|
||||||
onRouteChange={(next) => {
|
|
||||||
setCurrentPath(next);
|
|
||||||
router.push(next === "/" ? "/edit" : `${next}/edit`);
|
|
||||||
}}
|
|
||||||
onPublish={async (next) => {
|
|
||||||
await persist(editKey, next);
|
|
||||||
}}
|
|
||||||
iframe={{ enabled: true }}
|
|
||||||
overrides={{
|
|
||||||
headerActions: () => (
|
|
||||||
<SaveButton currentPath={editKey} onSave={persist} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ShopifyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Render } from "@reacteditor/core";
|
|
||||||
import "@reacteditor/core/dist/index.css";
|
|
||||||
import { createConfig } from "@/editor/config";
|
|
||||||
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
|
|
||||||
|
|
||||||
const SHOPIFY_DOMAIN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
|
|
||||||
const STOREFRONT_TOKEN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
|
||||||
|
|
||||||
export default function RenderClient({
|
|
||||||
data,
|
|
||||||
route,
|
|
||||||
}: {
|
|
||||||
data: { root: any; content: any[] };
|
|
||||||
route: string;
|
|
||||||
}) {
|
|
||||||
const config = useMemo(
|
|
||||||
() =>
|
|
||||||
createConfig({
|
|
||||||
domain: SHOPIFY_DOMAIN,
|
|
||||||
token: STOREFRONT_TOKEN || null,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
|
|
||||||
<Render config={config as any} data={data as any} metadata={{ route }} />
|
|
||||||
</ShopifyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>React Editor — Shopify Demo</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import type { Schema } from "@/lib/schema.server";
|
|
||||||
|
|
||||||
export type ResolvedEntry = {
|
|
||||||
key: string;
|
|
||||||
data: { root: any; content: any[] };
|
|
||||||
params: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEMPLATE_PATTERNS: { key: string; prefix: string; param: string }[] = [
|
|
||||||
{ key: "/products/*", prefix: "/products/", param: "handle" },
|
|
||||||
{ key: "/collections/*", prefix: "/collections/", param: "handle" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function resolveSchemaEntry(
|
|
||||||
schema: Schema,
|
|
||||||
route: string,
|
|
||||||
): ResolvedEntry | null {
|
|
||||||
if (schema[route]) {
|
|
||||||
return { key: route, data: schema[route], params: {} };
|
|
||||||
}
|
|
||||||
for (const { key, prefix, param } of TEMPLATE_PATTERNS) {
|
|
||||||
if (route.startsWith(prefix) && route.length > prefix.length && schema[key]) {
|
|
||||||
const value = route.slice(prefix.length);
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
data: applyParams(schema[key], { [param]: value }),
|
|
||||||
params: { [param]: value },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function templateKeyForRoute(route: string): string | null {
|
|
||||||
for (const { key, prefix } of TEMPLATE_PATTERNS) {
|
|
||||||
if (route.startsWith(prefix) && route.length > prefix.length) return key;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyParams(
|
|
||||||
data: { root: any; content: any[] },
|
|
||||||
params: Record<string, string>,
|
|
||||||
): { root: any; content: any[] } {
|
|
||||||
const handle = params.handle;
|
|
||||||
if (!handle) return data;
|
|
||||||
const content = data.content.map((block) => {
|
|
||||||
const props = block?.props ?? {};
|
|
||||||
if ("product" in props && (props.product == null || !props.product?.handle)) {
|
|
||||||
return { ...block, props: { ...props, product: { handle } } };
|
|
||||||
}
|
|
||||||
if ("collection" in props && (props.collection == null || !props.collection?.handle)) {
|
|
||||||
return { ...block, props: { ...props, collection: { handle } } };
|
|
||||||
}
|
|
||||||
return block;
|
|
||||||
});
|
|
||||||
return { ...data, content };
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import schemaJson from "../app.schema.json";
|
|
||||||
|
|
||||||
export type Schema = Record<string, { root: any; content: any[] }>;
|
|
||||||
|
|
||||||
export async function readSchema(): Promise<Schema> {
|
|
||||||
return schemaJson as Schema;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
module.exports = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
typescript: {
|
|
||||||
// Copied editor components carry a few legacy `any` paths that don't
|
|
||||||
// affect runtime. Ignoring lets the demo build/run without a port.
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
transpilePackages: [
|
|
||||||
"@reacteditor/core",
|
|
||||||
"@reacteditor/plugin-ai",
|
|
||||||
"@reacteditor/plugin-tailwind-cdn",
|
|
||||||
"@reacteditor/field-shopify",
|
|
||||||
"@reacteditor/field-google-fonts",
|
|
||||||
],
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{ protocol: "https", hostname: "images.unsplash.com" },
|
|
||||||
{ protocol: "https", hostname: "cdn.shopify.com" },
|
|
||||||
{ protocol: "https", hostname: "logo.clearbit.com" },
|
|
||||||
{ protocol: "https", hostname: "picsum.photos" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
17
package.json
17
package.json
@@ -2,13 +2,15 @@
|
|||||||
"name": "react-editor-demo",
|
"name": "react-editor-demo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "next lint"
|
"start": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3.0.74",
|
||||||
"@ai-sdk/react": "^2.0.0",
|
"@ai-sdk/react": "^2.0.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
@@ -37,7 +39,6 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.16.0",
|
"framer-motion": "^12.16.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.0.0",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.0.0",
|
"react-router": "^7.0.0",
|
||||||
@@ -51,8 +52,8 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"eslint": "^9.0.0",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint-config-next": "^16.0.0",
|
"typescript": "^5.5.4",
|
||||||
"typescript": "^5.5.4"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/App.tsx
Normal file
87
src/App.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
App as ReactEditorApp,
|
||||||
|
blocksPlugin,
|
||||||
|
outlinePlugin,
|
||||||
|
} from "@reacteditor/core";
|
||||||
|
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
|
||||||
|
import { aiPlugin } from "@reacteditor/plugin-ai";
|
||||||
|
import "@reacteditor/core/dist/index.css";
|
||||||
|
import "@reacteditor/plugin-ai/styles.css";
|
||||||
|
import { createConfig } from "@/editor/config";
|
||||||
|
import { ShopifyProvider } from "@/editor/contexts/shopify-context";
|
||||||
|
import schemaJson from "../app.schema.json";
|
||||||
|
|
||||||
|
type Pages = Record<string, { root: any; content: any[] }>;
|
||||||
|
|
||||||
|
const SHOPIFY_DOMAIN =
|
||||||
|
(import.meta.env.VITE_SHOPIFY_DOMAIN as string | undefined) ?? "mock.shop";
|
||||||
|
const STOREFRONT_TOKEN =
|
||||||
|
(import.meta.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN as
|
||||||
|
| string
|
||||||
|
| undefined) ?? "";
|
||||||
|
|
||||||
|
function readPathname() {
|
||||||
|
if (typeof window === "undefined") return "/";
|
||||||
|
const p = window.location.pathname;
|
||||||
|
return p === "" ? "/" : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const pages = schemaJson as Pages;
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>(readPathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => setCurrentPath(readPathname());
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const config = useMemo(
|
||||||
|
() =>
|
||||||
|
createConfig({
|
||||||
|
domain: SHOPIFY_DOMAIN,
|
||||||
|
token: STOREFRONT_TOKEN || null,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const plugins = useMemo(
|
||||||
|
() => [
|
||||||
|
createTailwindCdnPlugin(),
|
||||||
|
aiPlugin({
|
||||||
|
api: "/api/chat",
|
||||||
|
attachments: true,
|
||||||
|
body: { route: currentPath },
|
||||||
|
}),
|
||||||
|
blocksPlugin(),
|
||||||
|
outlinePlugin(),
|
||||||
|
],
|
||||||
|
[currentPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePublish = useCallback(
|
||||||
|
(data: any, route?: string) => {
|
||||||
|
const path = route ?? currentPath;
|
||||||
|
if (typeof window !== "undefined" && window.parent !== window) {
|
||||||
|
window.parent.postMessage({ type: "PUBLISH", path, data }, "*");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100vh", width: "100vw" }}>
|
||||||
|
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
|
||||||
|
<ReactEditorApp
|
||||||
|
config={config as any}
|
||||||
|
pages={pages as any}
|
||||||
|
currentPath={currentPath}
|
||||||
|
plugins={plugins}
|
||||||
|
iframe={{ enabled: true }}
|
||||||
|
onPublish={handlePublish}
|
||||||
|
/>
|
||||||
|
</ShopifyProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
10
src/vite-env.d.ts
vendored
Normal file
10
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_SHOPIFY_DOMAIN?: string;
|
||||||
|
readonly VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -1,45 +1,27 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": [
|
"useDefineForClassFields": true,
|
||||||
"dom",
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"dom.iterable",
|
|
||||||
"ES2022"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"strict": true,
|
||||||
"plugins": [
|
"skipLibCheck": true,
|
||||||
{
|
"esModuleInterop": true,
|
||||||
"name": "next"
|
"allowJs": true,
|
||||||
}
|
"types": ["node", "vite/client"],
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": [
|
"@/*": ["./*"],
|
||||||
"./*"
|
"~/*": ["./*"]
|
||||||
],
|
|
||||||
"@/*": [
|
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", "api", "editor", "vite.config.ts"],
|
||||||
"next-env.d.ts",
|
"exclude": ["node_modules", "dist"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
72
vite.config.ts
Normal file
72
vite.config.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function apiChatPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "api-chat-middleware",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api/chat", async (req, res, next) => {
|
||||||
|
if (req.method !== "POST") return next();
|
||||||
|
try {
|
||||||
|
const mod = await server.ssrLoadModule("/api/chat.ts");
|
||||||
|
const handler = (mod as { POST: (req: Request) => Promise<Response> })
|
||||||
|
.POST;
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of req) chunks.push(c as Buffer);
|
||||||
|
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const [k, v] of Object.entries(req.headers)) {
|
||||||
|
if (Array.isArray(v)) headers.set(k, v.join(", "));
|
||||||
|
else if (typeof v === "string") headers.set(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchReq = new Request(
|
||||||
|
`http://localhost${req.url ?? "/api/chat"}`,
|
||||||
|
{ method: "POST", headers, body },
|
||||||
|
);
|
||||||
|
const response = await handler(fetchReq);
|
||||||
|
|
||||||
|
res.statusCode = response.status;
|
||||||
|
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||||
|
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
res.write(Buffer.from(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/api/chat error", err);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(err instanceof Error ? err.message : "internal error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
|
for (const k of Object.keys(env)) {
|
||||||
|
if (process.env[k] === undefined) process.env[k] = env[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react(), apiChatPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "."),
|
||||||
|
"~": path.resolve(__dirname, "."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user