diff --git a/src/app/api/app/load/route.ts b/src/app/api/app/load/route.ts index 11bbb6d..6eef7e9 100644 --- a/src/app/api/app/load/route.ts +++ b/src/app/api/app/load/route.ts @@ -30,12 +30,6 @@ const jwtSchema = z.object({ }); export async function GET(request: NextRequest) { - function appendExchangeToken(url: string, token: string): string { - const delimiter = new URL(url, env.APP_ORIGIN).search ? '&' : '?'; - - return `${url}${delimiter}exchangeToken=${token}`; - } - const parsedParams = queryParamSchema.safeParse( Object.fromEntries(request.nextUrl.searchParams) ); @@ -65,7 +59,14 @@ export async function GET(request: NextRequest) { const exchangeToken = await db.saveClientToken(clientToken); - return NextResponse.redirect(new URL(appendExchangeToken(path, exchangeToken), env.APP_ORIGIN), { + // IMPORTANT: product names can contain '#' and BigCommerce may include them in the `url`. + // If we append query params by string concatenation, we can accidentally place `exchangeToken` + // after the '#' fragment, which browsers do not send to the server. Always mutate `searchParams` + // on a URL object so the token is guaranteed to be in the query string. + const redirectUrl = new URL(path, env.APP_ORIGIN); + redirectUrl.searchParams.set('exchangeToken', exchangeToken); + + return NextResponse.redirect(redirectUrl, { status: 302, statusText: 'Found', }); diff --git a/src/app/productDescription/[productId]/form.tsx b/src/app/productDescription/[productId]/form.tsx index ee53227..ede2ab3 100644 --- a/src/app/productDescription/[productId]/form.tsx +++ b/src/app/productDescription/[productId]/form.tsx @@ -12,6 +12,9 @@ import Loader from '~/components/Loader'; import { useAppContext } from '~/context/AppContext'; import { useTracking } from '~/hooks/useTracking'; +const sanitizeProductNameForGeneration = (name: string) => + name.replaceAll('#', '').replace(/\s{2,}/g, ' ').trim(); + export default function Form({ product, csrfToken, @@ -43,10 +46,19 @@ export default function Form({ const handleGenerateDescription = async () => { setIsLoading(true); + + // BigCommerce product names may contain '#' (e.g. for internal naming). For the + // /productDescription/${id} generator flow we strip those symbols only for the AI + // generation request, then keep the original name unchanged everywhere else. + const productForGeneration = { + ...product, + name: sanitizeProductNameForGeneration(product.name), + } satisfies Product | NewProduct; + const res = await fetch(`/api/generateDescription`, { method: 'POST', body: JSON.stringify( - prepareAiPromptAttributes(currentAttributes, product) + prepareAiPromptAttributes(currentAttributes, productForGeneration) ), headers: { 'X-CSRF-Token': csrfToken, diff --git a/src/app/productDescription/[productId]/page.tsx b/src/app/productDescription/[productId]/page.tsx index d5c2cfa..1098af7 100644 --- a/src/app/productDescription/[productId]/page.tsx +++ b/src/app/productDescription/[productId]/page.tsx @@ -6,13 +6,20 @@ import { headers } from 'next/headers'; interface PageProps { params: { productId: string }; - searchParams: { product_name: string; exchangeToken: string }; + searchParams: { product_name?: string; exchangeToken?: string }; } export default async function Page(props: PageProps) { const { productId } = props.params; const { product_name: name, exchangeToken } = props.searchParams; + if (!exchangeToken) { + // This typically happens when a product name contains an unencoded '#', which turns the rest of + // the URL into a fragment (not sent to the server). The /api/app/load redirect now ensures the + // exchangeToken is always appended before any fragment, but keep this guard to avoid a hard crash. + throw new Error('Missing exchange token. Try to re-open the app.'); + } + const authToken = await db.getClientTokenMaybeAndDelete(exchangeToken) || 'missing'; const authorized = authorize(authToken);