Migrate to vite
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Shopify (defaults to mock.shop if unset)
|
||||
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
|
||||
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
VITE_SHOPIFY_DOMAIN=mock.shop
|
||||
VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN=
|
||||
|
||||
# AI Gateway (Vercel) — used by /api/chat. Required for the AI panel.
|
||||
AI_GATEWAY_API_KEY=
|
||||
# Anthropic — used by /api/chat. Required for the AI panel.
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,8 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
next-env.d.ts
|
||||
*.log
|
||||
|
||||
20
README.md
20
README.md
@@ -1,15 +1,15 @@
|
||||
# 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
|
||||
|
||||
- **`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.
|
||||
- **`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/[[...path]]/page.tsx`** — view route. Reads the schema and mounts `<Render>`.
|
||||
- **`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.).
|
||||
- **`app.schema.json`** — source of truth for every page in the demo. Shape: `{ "/": { root, content }, "/about": { ... }, ... }`.
|
||||
- **`api/chat.ts`** — `aiPlugin` endpoint exposed in dev via a Vite middleware. Talks to Claude via `@ai-sdk/anthropic` with the editor + Shopify tool surface (`updatePage`, `searchProducts`, etc.).
|
||||
|
||||
## Run
|
||||
|
||||
@@ -19,14 +19,10 @@ yarn install
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
`/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,
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
reactEditorTools,
|
||||
@@ -20,9 +21,6 @@ import {
|
||||
GET_COLLECTION_PRODUCTS_QUERY,
|
||||
} from "@/editor/graphql/collections";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type Body = {
|
||||
messages: UIMessage[];
|
||||
editorContext?: Parameters<typeof getEditorContext>[0];
|
||||
@@ -48,11 +46,11 @@ export async function POST(req: Request) {
|
||||
|
||||
const credentials = {
|
||||
domain:
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ??
|
||||
process.env.VITE_SHOPIFY_DOMAIN ??
|
||||
process.env.SHOPIFY_DOMAIN ??
|
||||
"mock.shop",
|
||||
token:
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||
process.env.VITE_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||
process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||
"",
|
||||
};
|
||||
@@ -211,7 +209,7 @@ export async function POST(req: Request) {
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
system: getEditorContext(editorContext),
|
||||
messages: await convertToModelMessages(messages),
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.74",
|
||||
"@ai-sdk/react": "^2.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
@@ -37,7 +39,6 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.16.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.0.0",
|
||||
@@ -51,8 +52,8 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
"@vitejs/plugin-react": "^4.3.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": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./*"
|
||||
],
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
"@/*": ["./*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["src", "api", "editor", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
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