Add React editor project
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
activateAccount,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, activationToken, password } = await req.json();
|
||||
|
||||
if (!id || !activationToken || !password) {
|
||||
return Response.json(
|
||||
{ error: 'This activation link is incomplete.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await activateAccount(
|
||||
toCustomerGid(id),
|
||||
activationToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { login, customerErrorMessage } from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { token, errors } = await login(email, password);
|
||||
|
||||
if (!token) {
|
||||
// Shopify distinguishes wrong-password from unknown-email; collapse both so
|
||||
// the form can't be used to enumerate accounts.
|
||||
return Response.json(
|
||||
{
|
||||
error: errors.length
|
||||
? 'Incorrect email or password.'
|
||||
: customerErrorMessage(errors),
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { logout } from '@/services/shopify/customer';
|
||||
import { getSessionToken, clearSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST() {
|
||||
const token = await getSessionToken();
|
||||
if (token) await logout(token);
|
||||
|
||||
await clearSessionToken();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
// Minimal session probe for the header menu — never returns the access token.
|
||||
export async function GET() {
|
||||
const token = await getSessionToken();
|
||||
if (!token) return Response.json({ customer: null });
|
||||
|
||||
const customer = await getCustomer(token, 0);
|
||||
if (!customer) return Response.json({ customer: null });
|
||||
|
||||
return Response.json({
|
||||
customer: {
|
||||
displayName: customer.displayName,
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getSessionToken } from '@/services/shopify/session';
|
||||
import { getCustomer } from '@/services/shopify/customer';
|
||||
|
||||
/**
|
||||
* Full customer record including orders, for the client-rendered order-history
|
||||
* block. `/api/account/me` stays the lightweight session probe the header uses
|
||||
* — it asks for zero orders — so the two don't fight over payload size.
|
||||
*
|
||||
* The access token never leaves the server: it is read from the session cookie
|
||||
* here and only the resolved customer is returned.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const token = await getSessionToken();
|
||||
if (!token) return Response.json({ customer: null }, { status: 401 });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const parsed = Number(searchParams.get('orders'));
|
||||
const orderCount = Number.isFinite(parsed)
|
||||
? Math.min(Math.max(Math.trunc(parsed), 1), 50)
|
||||
: 20;
|
||||
|
||||
const customer = await getCustomer(token, orderCount);
|
||||
if (!customer) return Response.json({ customer: null }, { status: 401 });
|
||||
|
||||
return Response.json({ customer });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { recoverPassword } from '@/services/shopify/customer';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (email) await recoverPassword(email);
|
||||
|
||||
// Always the same response, so the form can't reveal who has an account.
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
createCustomer,
|
||||
login,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password, firstName, lastName } = await req.json();
|
||||
|
||||
if (!email || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Enter your email and password.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { errors } = await createCustomer({
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sign the new customer straight in. Accounts needing email confirmation
|
||||
// won't return a token yet, which is not an error.
|
||||
const { token } = await login(email, password);
|
||||
|
||||
if (token) {
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true, signedIn: true });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, signedIn: false });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
resetPassword,
|
||||
toCustomerGid,
|
||||
customerErrorMessage,
|
||||
} from '@/services/shopify/customer';
|
||||
import { setSessionToken } from '@/services/shopify/session';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { id, resetToken, password } = await req.json();
|
||||
|
||||
if (!id || !resetToken || !password) {
|
||||
return Response.json({ error: 'This reset link is incomplete.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { token, errors } = await resetPassword(
|
||||
toCustomerGid(id),
|
||||
resetToken,
|
||||
password
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return Response.json({ error: customerErrorMessage(errors) }, { status: 400 });
|
||||
}
|
||||
|
||||
await setSessionToken(token.accessToken, token.expiresAt);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
searchProducts,
|
||||
getProduct,
|
||||
getProductsPage,
|
||||
getCollections,
|
||||
getCollectionProductsPage,
|
||||
isDefaultTitleOption,
|
||||
isDefaultTitleSelection,
|
||||
} from '@/services/shopify/catalog';
|
||||
|
||||
// Streaming needs the Node runtime here because the Storefront helpers run
|
||||
// server-side on each tool call.
|
||||
export const maxDuration = 30;
|
||||
|
||||
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
|
||||
|
||||
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
|
||||
|
||||
You help shoppers find products, compare options, and understand what the store
|
||||
carries. You have tools that read live catalogue data — always use them rather
|
||||
than guessing, and never invent products, prices, availability, or policies.
|
||||
|
||||
Guidelines:
|
||||
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
|
||||
something vague like "what do you have?", call listCollections or
|
||||
searchCatalogue to ground your answer.
|
||||
- Prices returned by the tools are in the store's currency; show them as given.
|
||||
- Link products as /products/{handle} and collections as /collections/{handle}
|
||||
so the shopper can click through.
|
||||
- Keep replies short and conversational — a sentence or two plus a compact list.
|
||||
Do not repeat the raw tool output; the interface already shows it.
|
||||
- If a tool returns nothing, say so plainly and suggest a different search.
|
||||
- You cannot place orders, change carts, process payments, or look up customer
|
||||
or order data. Say so and point the shopper to the relevant page instead.`;
|
||||
|
||||
// Trim the Storefront payloads to what the model actually needs to answer.
|
||||
const summariseProduct = (product: {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
productType?: string;
|
||||
tags?: string[];
|
||||
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
|
||||
variants?: {
|
||||
edges: Array<{
|
||||
node: {
|
||||
title: string;
|
||||
availableForSale: boolean;
|
||||
selectedOptions?: Array<{ name: string; value: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
images?: { edges: Array<{ node: { url: string } }> };
|
||||
options?: Array<{ name: string; values: string[] }>;
|
||||
}) => ({
|
||||
title: product.title,
|
||||
handle: product.handle,
|
||||
url: `/products/${product.handle}`,
|
||||
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
|
||||
image: product.images?.edges[0]?.node.url ?? null,
|
||||
description: product.description?.slice(0, 300) ?? null,
|
||||
productType: product.productType || null,
|
||||
tags: product.tags ?? [],
|
||||
options: product.options
|
||||
?.filter((option) => !isDefaultTitleOption(option))
|
||||
.map((option) => ({
|
||||
name: option.name,
|
||||
values: option.values,
|
||||
})),
|
||||
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const { messages } = await req.json();
|
||||
const openrouter = createOpenRouter({ apiKey });
|
||||
|
||||
const result = streamText({
|
||||
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
|
||||
// renders it via the Reasoning component.
|
||||
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
// Let the model call a tool, read the result, then answer.
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: {
|
||||
searchCatalogue: tool({
|
||||
description:
|
||||
'Search the store for products matching a term. Use for any question about what the store sells.',
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
|
||||
limit: z.number().int().min(1).max(10).default(5),
|
||||
}),
|
||||
execute: async ({ query, limit }) => {
|
||||
const { products, totalCount } = await searchProducts({
|
||||
query,
|
||||
first: limit,
|
||||
});
|
||||
return {
|
||||
totalCount,
|
||||
products: products.map(summariseProduct),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getProductDetails: tool({
|
||||
description:
|
||||
'Get full details for one product by its handle, including options, variants, and stock.',
|
||||
inputSchema: z.object({
|
||||
handle: z
|
||||
.string()
|
||||
.describe('The product handle, e.g. "flowguard-jacket".'),
|
||||
}),
|
||||
execute: async ({ handle }) => {
|
||||
const product = await getProduct(handle);
|
||||
if (!product) return { found: false, handle };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
...summariseProduct(product),
|
||||
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
|
||||
title: node.title,
|
||||
available: node.availableForSale,
|
||||
price: `${node.price.amount} ${node.price.currencyCode}`,
|
||||
options: node.selectedOptions?.filter(
|
||||
(option) => !isDefaultTitleSelection(option)
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
listCollections: tool({
|
||||
description:
|
||||
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(25).default(10),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const collections = await getCollections(limit);
|
||||
return {
|
||||
collections: collections.map((collection) => ({
|
||||
title: collection.title,
|
||||
handle: collection.handle,
|
||||
url: `/collections/${collection.handle}`,
|
||||
description: collection.description?.slice(0, 200) ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getCollectionProducts: tool({
|
||||
description:
|
||||
'List the products inside one collection, by collection handle.',
|
||||
inputSchema: z.object({
|
||||
handle: z.string().describe('The collection handle, e.g. "men".'),
|
||||
limit: z.number().int().min(1).max(20).default(8),
|
||||
}),
|
||||
execute: async ({ handle, limit }) => {
|
||||
const page = await getCollectionProductsPage(handle, { first: limit });
|
||||
if (!page.collection) return { found: false, handle };
|
||||
|
||||
return {
|
||||
found: true,
|
||||
collection: page.collection.title,
|
||||
url: `/collections/${handle}`,
|
||||
products: page.products.map(summariseProduct),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
browseProducts: tool({
|
||||
description:
|
||||
'Browse the newest products when the shopper has no specific search term.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().int().min(1).max(20).default(8),
|
||||
}),
|
||||
execute: async ({ limit }) => {
|
||||
const page = await getProductsPage({
|
||||
first: limit,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true,
|
||||
});
|
||||
return { products: page.products.map(summariseProduct) };
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// sendReasoning forwards reasoning parts to the client; without it the
|
||||
// stream carries text and tool calls only.
|
||||
return result.toUIMessageStreamResponse({ sendReasoning: true });
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
|
||||
|
||||
// DELETE /api/media/:id — remove a media asset.
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const upstream = await fetch(
|
||||
`${CLOUD_BASE}/api/media/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "x-api-key": FRONTEND_API_KEY },
|
||||
},
|
||||
);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
|
||||
|
||||
// GET /api/media — list/search media. Forwards query + cursor params.
|
||||
export async function GET(req: NextRequest) {
|
||||
const incoming = new URL(req.url);
|
||||
const url = new URL("/api/media", CLOUD_BASE);
|
||||
const query = incoming.searchParams.get("query");
|
||||
const cursor = incoming.searchParams.get("cursor");
|
||||
if (query) url.searchParams.set("query", query);
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
|
||||
const upstream = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "x-api-key": FRONTEND_API_KEY },
|
||||
});
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/media — upload. Forwards the multipart body as-is.
|
||||
export async function POST(req: NextRequest) {
|
||||
const upstream = await fetch(`${CLOUD_BASE}/api/media`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": req.headers.get("content-type") ?? "",
|
||||
"x-api-key": FRONTEND_API_KEY,
|
||||
},
|
||||
body: req.body,
|
||||
// @ts-expect-error - duplex is valid but missing from the lib types.
|
||||
duplex: "half",
|
||||
});
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
upstream.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { findPageRoute } from '@/lib/pages';
|
||||
|
||||
// Touches the filesystem, so it must never be statically optimised.
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Resolves a route key to its `page.json` on disk.
|
||||
*
|
||||
* The path is built from the registry in `lib/pages.ts`, never from the request
|
||||
* body, so an unknown or crafted route key is rejected outright rather than
|
||||
* escaping the `app/` directory. The realpath check is belt-and-braces for the
|
||||
* same thing.
|
||||
*/
|
||||
function resolvePageFile(routeKey: string): string | null {
|
||||
const route = findPageRoute(routeKey);
|
||||
if (!route) return null;
|
||||
|
||||
const appDir = path.join(process.cwd(), 'app');
|
||||
const file = path.join(appDir, route.dir, 'page.json');
|
||||
|
||||
return file.startsWith(appDir + path.sep) ? file : null;
|
||||
}
|
||||
|
||||
// Props of blocks marked `global: true` (header, footer) live in one file that
|
||||
// every page.json references, so editing them once updates every route.
|
||||
const GLOBALS_FILE = path.join(process.cwd(), 'app.globals.json');
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const routeKey = new URL(request.url).searchParams.get('route') ?? '/';
|
||||
const file = resolvePageFile(routeKey);
|
||||
|
||||
if (!file) {
|
||||
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const page = await readJson(file);
|
||||
// A route with no page.json yet is a new page, not an error.
|
||||
if (!page) return Response.json({ page: null });
|
||||
|
||||
const globals = await readJson(GLOBALS_FILE);
|
||||
return Response.json({ page: { ...page, globals: globals ?? {} } });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let body: { route?: string; page?: unknown };
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'Expected a JSON body.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const routeKey = body.route ?? '';
|
||||
const file = resolvePageFile(routeKey);
|
||||
|
||||
if (!file) {
|
||||
return Response.json({ error: `Unknown route: ${routeKey}` }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!body.page || typeof body.page !== 'object') {
|
||||
return Response.json({ error: 'Expected a page object.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Globals belong to the whole site, not this route, so they go to their own
|
||||
// file and are stripped from the page before it is written.
|
||||
const { globals, ...page } = body.page as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
await writeFile(file, `${JSON.stringify(page, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (globals && typeof globals === 'object') {
|
||||
await writeFile(
|
||||
GLOBALS_FILE,
|
||||
`${JSON.stringify(globals, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ file: path.relative(process.cwd(), file) });
|
||||
} catch (err) {
|
||||
// Read-only filesystems (most serverless hosts) land here. Say so plainly
|
||||
// rather than reporting a save that did not happen.
|
||||
console.error(`Failed to write ${file}:`, err);
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
'Could not write page.json. The filesystem is read-only — run the editor locally to save.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user