Initial commit

This commit is contained in:
Rami Bitar
2026-05-02 09:18:15 -04:00
commit 7348e430c0
96 changed files with 15753 additions and 0 deletions

26
app/[[...path]]/page.tsx Normal file
View 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
View 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();
}

View 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 },
);
}
}

View 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
View 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
View 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>
);
}