Initial commit
This commit is contained in:
26
app/[[...path]]/page.tsx
Normal file
26
app/[[...path]]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { readSchema } from "~/lib/schema.server";
|
||||
import RenderClient from "~/components/RenderClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ path?: string[] }>;
|
||||
}) {
|
||||
const { path } = await params;
|
||||
const segments = (path ?? []).filter(Boolean);
|
||||
|
||||
// The /edit branch is handled by app/edit; this catch-all only serves view.
|
||||
if (segments[0] === "edit") return notFound();
|
||||
|
||||
const route = "/" + segments.join("/");
|
||||
const lookup = route === "/" ? "/" : route.replace(/\/$/, "");
|
||||
|
||||
const schema = await readSchema();
|
||||
const data = schema[lookup];
|
||||
if (!data) return notFound();
|
||||
|
||||
return <RenderClient data={data} route={lookup} />;
|
||||
}
|
||||
244
app/api/chat/route.ts
Normal file
244
app/api/chat/route.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
convertToModelMessages,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
tool,
|
||||
type UIMessage,
|
||||
} from "ai";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
reactEditorTools,
|
||||
getEditorContext,
|
||||
} from "@reacteditor/plugin-ai/server";
|
||||
import { patchRoute, readSchema } from "~/lib/schema.server";
|
||||
import { shopifyFetch } from "~/editor/services/shopify/client";
|
||||
import {
|
||||
GET_PRODUCTS_QUERY,
|
||||
GET_PRODUCT_QUERY,
|
||||
} from "~/editor/graphql/products";
|
||||
import {
|
||||
GET_COLLECTIONS_QUERY,
|
||||
GET_COLLECTION_PRODUCTS_QUERY,
|
||||
} from "~/editor/graphql/collections";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type Body = {
|
||||
messages: UIMessage[];
|
||||
editorContext?: Parameters<typeof getEditorContext>[0];
|
||||
route?: string;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { messages, editorContext, route } = (await req.json()) as Body;
|
||||
|
||||
const updatePage = tool({
|
||||
description:
|
||||
"Persist the page schema (root + content) for a given route to app.schema.json.",
|
||||
inputSchema: z.object({
|
||||
data: z.object({ root: z.any(), content: z.any() }),
|
||||
}),
|
||||
execute: async ({ data }) => {
|
||||
const target = route || "/";
|
||||
await patchRoute(target, data);
|
||||
return { ok: true, route: target };
|
||||
},
|
||||
});
|
||||
|
||||
const generateImage = tool({
|
||||
description: "Generate an image from a prompt and return its URL.",
|
||||
inputSchema: z.object({
|
||||
prompt: z.string(),
|
||||
width: z.number().int().positive().optional(),
|
||||
height: z.number().int().positive().optional(),
|
||||
}),
|
||||
execute: async ({ width = 768, height = 768 }) => ({
|
||||
url: `https://picsum.photos/${width}/${height}?random=${Math.floor(
|
||||
Math.random() * 1_000_000,
|
||||
)}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const credentials = {
|
||||
domain:
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ??
|
||||
process.env.SHOPIFY_DOMAIN ??
|
||||
"mock.shop",
|
||||
token:
|
||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||
process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN ??
|
||||
"",
|
||||
};
|
||||
|
||||
const searchProducts = tool({
|
||||
description:
|
||||
"Search the Shopify store for products. Returns up to `limit` products matching `query`.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().optional(),
|
||||
limit: z.number().int().min(1).max(20).optional(),
|
||||
}),
|
||||
execute: async ({ query, limit = 8 }) => {
|
||||
try {
|
||||
const res = await shopifyFetch<any>({
|
||||
query: GET_PRODUCTS_QUERY,
|
||||
variables: {
|
||||
first: limit,
|
||||
query: query ?? null,
|
||||
sortKey: query ? "RELEVANCE" : "BEST_SELLING",
|
||||
reverse: false,
|
||||
},
|
||||
credentials,
|
||||
});
|
||||
const products = (res.data?.products?.edges ?? []).map((e: any) => {
|
||||
const n = e.node;
|
||||
return {
|
||||
id: n.id,
|
||||
handle: n.handle,
|
||||
title: n.title,
|
||||
description: n.description,
|
||||
featuredImage: n.images?.edges?.[0]?.node ?? null,
|
||||
priceRange: n.priceRange,
|
||||
};
|
||||
});
|
||||
return { ok: true, products };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "fetch failed",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getProductByHandle = tool({
|
||||
description: "Fetch a single product by its handle.",
|
||||
inputSchema: z.object({ handle: z.string() }),
|
||||
execute: async ({ handle }) => {
|
||||
try {
|
||||
const res = await shopifyFetch<any>({
|
||||
query: GET_PRODUCT_QUERY,
|
||||
variables: { handle },
|
||||
credentials,
|
||||
});
|
||||
const p = res.data?.product ?? null;
|
||||
if (!p) return { ok: false, error: "not_found" };
|
||||
return {
|
||||
ok: true,
|
||||
product: {
|
||||
id: p.id,
|
||||
handle: p.handle,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
featuredImage: p.images?.edges?.[0]?.node ?? null,
|
||||
priceRange: p.priceRange,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "fetch failed",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const searchCollections = tool({
|
||||
description: "List up to `limit` collections from the Shopify store.",
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(20).optional(),
|
||||
}),
|
||||
execute: async ({ limit = 8 }) => {
|
||||
try {
|
||||
const res = await shopifyFetch<any>({
|
||||
query: GET_COLLECTIONS_QUERY,
|
||||
variables: { first: limit },
|
||||
credentials,
|
||||
});
|
||||
const collections = (res.data?.collections?.edges ?? []).map(
|
||||
(e: any) => {
|
||||
const n = e.node;
|
||||
return {
|
||||
id: n.id,
|
||||
handle: n.handle,
|
||||
title: n.title,
|
||||
description: n.description,
|
||||
image: n.image ?? null,
|
||||
};
|
||||
},
|
||||
);
|
||||
return { ok: true, collections };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "fetch failed",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getCollectionByHandle = tool({
|
||||
description:
|
||||
"Fetch a single collection by its handle, including a small page of its products.",
|
||||
inputSchema: z.object({
|
||||
handle: z.string(),
|
||||
limit: z.number().int().min(1).max(20).optional(),
|
||||
}),
|
||||
execute: async ({ handle, limit = 8 }) => {
|
||||
try {
|
||||
const res = await shopifyFetch<any>({
|
||||
query: GET_COLLECTION_PRODUCTS_QUERY,
|
||||
variables: {
|
||||
handle,
|
||||
first: limit,
|
||||
sortKey: "BEST_SELLING",
|
||||
reverse: false,
|
||||
},
|
||||
credentials,
|
||||
});
|
||||
const c = res.data?.collection ?? null;
|
||||
if (!c) return { ok: false, error: "not_found" };
|
||||
return {
|
||||
ok: true,
|
||||
collection: {
|
||||
id: c.id,
|
||||
handle: c.handle,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
image: c.image ?? null,
|
||||
products: (c.products?.edges ?? []).map((e: any) => ({
|
||||
id: e.node.id,
|
||||
handle: e.node.handle,
|
||||
title: e.node.title,
|
||||
featuredImage: e.node.images?.edges?.[0]?.node ?? null,
|
||||
priceRange: e.node.priceRange,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "fetch failed",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
system: getEditorContext(editorContext),
|
||||
messages: await convertToModelMessages(messages),
|
||||
tools: {
|
||||
...reactEditorTools,
|
||||
updatePage,
|
||||
generateImage,
|
||||
searchProducts,
|
||||
getProductByHandle,
|
||||
searchCollections,
|
||||
getCollectionByHandle,
|
||||
},
|
||||
stopWhen: stepCountIs(50),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
21
app/api/save-schema/route.ts
Normal file
21
app/api/save-schema/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { patchRoute } from "~/lib/schema.server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { route, data } = await request.json();
|
||||
if (typeof route !== "string" || !data || typeof data !== "object") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "expected { route, data }" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const schema = await patchRoute(route, data);
|
||||
return NextResponse.json({ ok: true, routes: Object.keys(schema) });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: err instanceof Error ? err.message : "save failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
21
app/edit/[[...path]]/page.tsx
Normal file
21
app/edit/[[...path]]/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readSchema } from "~/lib/schema.server";
|
||||
import EditorClient from "~/components/EditorClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ path?: string[] }>;
|
||||
}) {
|
||||
const { path } = await params;
|
||||
const segments = (path ?? []).filter(Boolean);
|
||||
const route = segments.length === 0 ? "/" : "/" + segments.join("/");
|
||||
const schema = await readSchema();
|
||||
|
||||
return (
|
||||
<div style={{ height: "100vh", width: "100vw" }}>
|
||||
<EditorClient initialSchema={schema} initialPath={route} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
app/globals.css
Normal file
81
app/globals.css
Normal file
@@ -0,0 +1,81 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-family-heading: var(--font-header);
|
||||
--font-family-header: var(--font-header);
|
||||
--font-family-body: var(--font-body);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--animate-marquee: marquee var(--duration) infinite linear;
|
||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
||||
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100% - var(--gap))); }
|
||||
}
|
||||
@keyframes marquee-vertical {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(calc(-100% - var(--gap))); }
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--font-header: system-ui, -apple-system, sans-serif;
|
||||
--font-body: system-ui, -apple-system, sans-serif;
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
--primary: #0a0a0a;
|
||||
--primary-foreground: #fafafa;
|
||||
--secondary: #f5f5f5;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #0a0a0a;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #a3a3a3;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { border-color: var(--border); }
|
||||
html, body { background-color: var(--background); color: var(--foreground); }
|
||||
body {
|
||||
font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji";
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
22
app/layout.tsx
Normal file
22
app/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user