M11 split: storefront routes and core libs#20
Conversation
Moves storefront route handlers, app routes, state contexts, i18n, and core lib updates into a focused PR under CodeRabbit limits. Made-with: Cursor
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 11 minutes and 23 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (13)
📝 WalkthroughWalkthroughAdds extensive storefront features: Payload CMS and Medusa integrations, new server APIs, many client/server components and contexts (cart, wishlist, checkout, cookie consent), i18n expansions, styling tokens, image/asset/config updates, caching/helpers, and multiple checkout/pickup/free-shipping flows. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant App as CookieConsentProvider
participant Consent as ConsentScript
participant Manager as ScriptManager
participant ThirdParty as ThirdPartyScript
participant Tracker
User->>App: clicks "Accept All" / save preferences
App->>Manager: register/update consent state
Manager-->>App: store registered scripts
App->>Manager: loadConsentedScripts(consent)
Manager->>Consent: loadScript(id)
Consent->>ThirdParty: append <script> / inline content
ThirdParty-->>Consent: onload event
Consent->>Manager: mark loaded
App->>Tracker: trackConsent(payload)
Tracker->>App: ok / enqueue retry on failure
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 4
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/storefront/package.json (1)
10-47:⚠️ Potential issue | 🔴 CriticalCI blocker: lockfile is out of sync with
package.json.The pipeline failure (
ERR_PNPM_OUTDATED_LOCKFILE) is rooted in this dependency/scripts change set. Untilpnpm-lock.yamlis regenerated and committed, install and CI will keep failing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/package.json` around lines 10 - 47, The lockfile is out of sync with the package.json changes (scripts like "typecheck" and hero-banner scripts and dependency updates such as "next": "16.1.6", "playwright": "^1.50.0", etc.), causing CI to fail with ERR_PNPM_OUTDATED_LOCKFILE; to fix, run the package manager install command that regenerates the lockfile (e.g., pnpm install) to update the lockfile, verify the generated lockfile is included in the repo, and commit the updated lockfile alongside the package.json changes so CI can pass.apps/storefront/src/app/globals.css (1)
173-186:⚠️ Potential issue | 🟠 MajorKeep a visible keyboard focus state.
The global reset removes the browser outline from every element, and the search-modal block then removes border and box-shadow with
!important. Plain links and the modal input end up with no visible focus indicator, which is an accessibility blocker. Scope the reset much more narrowly and keep a consistent:focus-visibletreatment for interactive controls.Also applies to: 374-387
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/globals.css` around lines 173 - 186, The global CSS currently strips outline for all elements and then overrides interactive controls, which removes visible focus for links and modal inputs; narrow the reset by removing the universal *:focus and *:focus-visible rules and instead target only non-interactive elements if needed, restore a consistent :focus-visible rule for interactive controls (anchors, button, input, textarea, select, [role="button"], etc.) to apply a visible border or ring, and remove any conflicting !important styles in the search-modal that clear borders/box-shadow so the :focus-visible styles on the modal input remain visible; locate and update the selectors in globals.css (the existing *:focus, *:focus-visible, and input:focus/textarea:focus/select:focus/button:focus-visible blocks) and adjust the search-modal CSS that uses !important to allow the focus styles to show.
🟠 Major comments (31)
apps/storefront/src/app/api/newsletter/route.ts-72-73 (1)
72-73:⚠️ Potential issue | 🟠 MajorDon’t log raw upstream error bodies containing potential PII.
Line [72] and Line [98] log
errTextdirectly. Upstream payloads often include user identifiers (email), which creates avoidable privacy/compliance risk in server logs.Also applies to: 97-99
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/newsletter/route.ts` around lines 72 - 73, The code currently logs raw upstream error bodies (errText) via console.error when the newsletter create contact call fails (see createRes and errText usage in the newsletter route handler); remove the direct logging of errText and instead log only non-sensitive context such as createRes.status, a concise descriptive message, and optionally a safe indicator like a truncated/hashed error id or an internal correlation id; update both occurrences that call console.error("[newsletter] Plunk create contact failed:", createRes.status, errText) (and the similar log around lines handling createRes.ok false) to avoid outputting upstream payloads containing PII.apps/storefront/src/app/api/newsletter/route.ts-57-68 (1)
57-68:⚠️ Potential issue | 🟠 MajorAdd timeouts to outbound Plunk requests.
Line [57] and Line [83] use
fetchwithout an abort timeout. Upstream slowness can tie up request workers and degrade API reliability under failure conditions.⏱️ Suggested pattern
+async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit, + timeoutMs = 8000 +) { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(id); + } +} @@ - const createRes = await fetch(`${PLUNK_BASE}/contacts`, { + const createRes = await fetchWithTimeout(`${PLUNK_BASE}/contacts`, { @@ - const memberRes = await fetch( + const memberRes = await fetchWithTimeout(Also applies to: 83-93
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/newsletter/route.ts` around lines 57 - 68, Outbound fetch calls in apps/storefront/src/app/api/newsletter/route.ts (the createRes fetch and the later fetch around lines 83-93) lack timeouts and can hang; update both calls to use an AbortController with a setTimeout to abort after a sensible timeout (e.g., 2–5s) or, better, extract a small helper like fetchWithTimeout that creates an AbortController, starts a timeout to call controller.abort(), passes controller.signal to fetch, and clears the timer on completion; ensure you handle AbortError/DOMException from aborted requests and clean up the timer in both success and error paths when calling the fetches that createRes and the subsequent Plunk request perform.apps/storefront/src/contexts/CheckoutCartContext.tsx-55-66 (1)
55-66:⚠️ Potential issue | 🟠 MajorAvoid silent fallback when provider is missing.
Line [58]–Line [65] masks wiring errors and can lead to broken totals/updates with no clear signal. Prefer throwing (same pattern as your other contexts).
✅ Suggested fix
export function useCheckoutCart(): CheckoutCartContextValue { const ctx = useContext(CheckoutCartContext); if (!ctx) { - return { - initialCart: null, - liveCart: null, - setLiveCart: () => {}, - cart: null, - selectedShippingAmount: null, - setSelectedShippingAmount: () => {}, - }; + throw new Error("useCheckoutCart must be used within CheckoutCartProvider"); } return ctx; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/contexts/CheckoutCartContext.tsx` around lines 55 - 66, The current useCheckoutCart() silently returns a noop/fallback object when CheckoutCartContext is missing, masking wiring errors; change it to mirror other hooks by throwing an explicit error when ctx is falsy (e.g. throw new Error("useCheckoutCart must be used within CheckoutCartProvider")), remove the silent fallback return, and ensure callers rely on the real CheckoutCartContext values (update references to useCheckoutCart and CheckoutCartContext if necessary).apps/storefront/src/lib/lexical-to-html.ts-45-48 (1)
45-48:⚠️ Potential issue | 🟠 MajorConstrain heading tags to an allowlist.
Line 45 interpolates
node.tagdirectly into HTML tag names. Restrict toh1–h6to avoid malformed or unsafe output.✅ Suggested guard
- const tag = (node.tag as string) || "h2"; + const rawTag = typeof node.tag === "string" ? node.tag.toLowerCase() : "h2"; + const tag = /^(h[1-6])$/.test(rawTag) ? rawTag : "h2";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/lexical-to-html.ts` around lines 45 - 48, The code interpolates node.tag directly into an HTML tag (variables tag, level, size in lexical-to-html.ts), so constrain tag to an allowlist of "h1"–"h6": validate node.tag against that list and if it is not one of those values, fall back to "h2"; compute level and size from the validated tag and then use the validated tag and children when returning the string to avoid malformed/unsafe output.apps/storefront/src/lib/server-cache.ts-48-53 (1)
48-53:⚠️ Potential issue | 🟠 MajorCurrent size cap is soft-only; cache can still grow unbounded.
If many fresh keys are inserted quickly,
pruneExpiredEntries()won’t reduce size until entries age out. Add hard-cap eviction whencache.sizeremains above threshold after pruning.📦 Suggested hard-cap eviction
export function setCache(key: string, data: unknown): void { cache.set(key, { data, expires: Date.now() + CACHE_TTL_MS }); if (cache.size > MAX_ENTRIES_SOFT) { pruneExpiredEntries(); + while (cache.size > MAX_ENTRIES_SOFT) { + const oldestKey = cache.keys().next().value as string | undefined; + if (!oldestKey) break; + cache.delete(oldestKey); + } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/server-cache.ts` around lines 48 - 53, setCache currently only calls pruneExpiredEntries(), so a storm of fresh keys can grow the cache past MAX_ENTRIES_SOFT; after calling pruneExpiredEntries() in setCache, check if cache.size is still > MAX_ENTRIES_SOFT and perform hard-cap eviction by deleting oldest entries until size <= MAX_ENTRIES_SOFT (use the Map insertion order: iterate cache.keys() and cache.delete(key) repeatedly). Keep using the existing stored value shape ({ data, expires }) and constants (cache, MAX_ENTRIES_SOFT, pruneExpiredEntries) so the new eviction runs only when pruning didn’t reduce size enough.apps/storefront/src/lib/lexical-to-html.ts-39-43 (1)
39-43:⚠️ Potential issue | 🟠 MajorWrap
case "text"declaration in a block to satisfy Biome.The
let outdeclaration in this switch clause triggerslint/correctness/noSwitchDeclarations(configured as an error in biome.json). Other cases like"heading"and"link"already use this pattern.🔧 Minimal lint-safe change
- case "text": - let out = text; - if (bold) out = `<strong>${out}</strong>`; - if (italic) out = `<em>${out}</em>`; - return out; + case "text": { + let out = text; + if (bold) out = `<strong>${out}</strong>`; + if (italic) out = `<em>${out}</em>`; + return out; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/lexical-to-html.ts` around lines 39 - 43, Wrap the case "text" clause in a block so the local declaration "let out" is scoped and satisfies Biome's noSwitchDeclarations rule; change the switch's case "text" to use { let out = text; if (bold) out = `<strong>${out}</strong>`; if (italic) out = `<em>${out}</em>`; return out; } following the same pattern used in the "heading" and "link" cases in lexical-to-html.ts.apps/storefront/src/lib/cart-data.ts-91-99 (1)
91-99:⚠️ Potential issue | 🟠 MajorAdd request timeouts for upstream cart fetches.
These Medusa calls are missing timeout protection that's consistently applied across other modules (subscriptions.ts, orders.ts, account-summary.ts). Without timeouts, slow upstream responses can stall route/render work indefinitely.
Suggested update
let res = await fetch(urlWithFields(CART_FIELDS_WITH_VARIANT_STOCK), { headers: headers(), cache: "no-store", + signal: AbortSignal.timeout(8000), }); if (!res.ok) { res = await fetch(urlWithFields("+items.*"), { headers: headers(), cache: "no-store", + signal: AbortSignal.timeout(8000), }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/cart-data.ts` around lines 91 - 99, The fetch calls that request cart data using urlWithFields(CART_FIELDS_WITH_VARIANT_STOCK) and the fallback urlWithFields("+items.*") currently lack timeouts; wrap each fetch in an AbortController with a timeout (matching the pattern used in subscriptions.ts/orders.ts/account-summary.ts) so the request is aborted after the configured timeout and the error is handled; ensure you create an AbortController, pass controller.signal into fetch(headers: headers(), cache: "no-store", signal: controller.signal), set a timer to call controller.abort() (and clear it on success), and surface or catch the abort error so slow upstream responses don't stall route/render work.apps/storefront/src/lib/format.ts-7-9 (1)
7-9:⚠️ Potential issue | 🟠 MajorAdd defensive guards to Intl formatting functions against invalid locale/currency/date inputs.
Intl.NumberFormatthrowsRangeErroron invalid ISO 4217 currency codes or invalid BCP 47 locale strings.Date.toLocaleDateStringproduces "Invalid Date" output on unparseable dates. Both functions accept potentially malformed values from route parameters and API responses without validation.Suggested fixes
export function formatCurrencyAmount( amount: number, routeLocale: string, currencyCode = "dkk" ): string { const intlLocale = intlLocaleFromRoute(routeLocale); const currency = currencyCode.toUpperCase(); + try { return new Intl.NumberFormat(intlLocale, { style: "currency", currency, minimumFractionDigits: 0, }).format(amount); + } catch { + return new Intl.NumberFormat("da-DK", { + style: "currency", + currency: "DKK", + minimumFractionDigits: 0, + }).format(amount); + } } export function formatLongDate(dateStr: string | undefined, routeLocale: string): string { if (!dateStr) return "–"; + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return "–"; - return new Date(dateStr).toLocaleDateString(intlLocaleFromRoute(routeLocale), { + return date.toLocaleDateString(intlLocaleFromRoute(routeLocale), { year: "numeric", month: "long", day: "numeric", }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/format.ts` around lines 7 - 9, The code must defensively validate locales, currencies, and dates before calling Intl APIs: update intlLocaleFromRoute to canonicalize/validate the incoming routeLocale via Intl.getCanonicalLocales (fall back to a safe default like 'en-US' or the incoming ROUTE_LOCALE_TO_INTL mapping) and then in every formatter that consumes it (e.g. any formatCurrency / formatNumber and formatDate utilities that use Intl.NumberFormat or Date.toLocaleDateString) wrap Intl.NumberFormat creation in a try/catch and validate currency codes with a simple /^[A-Z]{3}$/ check, and validate dates by checking isFinite(new Date(value).getTime()) before calling toLocaleDateString; on validation failure return a clear fallback string or null and log/debug the bad input.apps/storefront/src/lib/orders.ts-81-95 (1)
81-95:⚠️ Potential issue | 🟠 MajorDon't forward the entire storefront cookie jar to Medusa.
cookieStore.toString()serializes every request cookie, so these calls will also send unrelated app, session, and analytics cookies upstream. Build theCookieheader from an allowlist of Medusa auth/session cookies only; otherwise a separate Medusa host can receive cookies it should never see.Also applies to: 124-130
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/orders.ts` around lines 81 - 95, cookieStore.toString() forwards the entire browser cookie jar (including app, session, analytics cookies) to Medusa; instead, read cookies from cookieStore and build the Cookie header using an allowlist of only Medusa-related names (e.g., auth/session cookie names used by Medusa) rather than cookieHeader = cookieStore.toString(); update the fetch call that sets headers (the block using baseHeaders(), cookieHeader and MEDUSA_URL in this file and the similar block at 124-130) to construct a Cookie string by extracting only allowlisted cookies from cookieStore (use cookieStore.get or equivalent) and include that string when non-empty.apps/storefront/src/lib/cart-display.ts-18-23 (1)
18-23:⚠️ Potential issue | 🟠 MajorThe shipping-unit heuristic will misprice real orders.
Amount-based guessing fails both ways:
995øre stays995, while a legitimate1200DKK charge becomes12. This needs explicit unit information from the source data, not a magnitude threshold.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/cart-display.ts` around lines 18 - 23, The current normalizeShippingForDisplay uses a magnitude heuristic (SHIPPING_MINOR_UNIT_THRESHOLD) that misinterprets real amounts; replace it with an explicit unit-aware API: change normalizeShippingForDisplay to accept an additional parameter indicating the unit (e.g., isMinorUnit: boolean or unit: 'minor'|'major'), remove SHIPPING_MINOR_UNIT_THRESHOLD and the threshold logic, and convert only when the caller explicitly states the amount is in minor units (e.g., divide by 100 when unit === 'minor'); update all call sites to pass the unit flag/enum and adjust/add tests to cover both minor- and major-unit inputs.apps/storefront/src/lib/medusa-products.ts-102-143 (1)
102-143:⚠️ Potential issue | 🟠 MajorDon't derive product availability from only the first variant.
firstVariantdrivesinStock,lowStock, andvariantIdhere. If the first option is sold out but another variant still has inventory, list/search cards will mark the whole product unavailable and may lose quick-add for a purchasable product. Compute availability across all variants, and only expose avariantIdwhen it matches the variant whose price/label you are actually showing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/medusa-products.ts` around lines 102 - 143, mapMedusaToProduct currently derives product availability from firstVariant only (firstVariant -> inStock, lowStock, variantId). Iterate over p.variants, call getVariantStockInfo(...) for each to compute aggregated availability: set inStock = true if any variant.inStock, lowStock = true if any variant.isLowStock (or choose a policy you prefer), and availableQuantity = sum of variant.availableQuantity; keep price/label selection using firstVariant (priceObj/variantTitle) but only set variantId when that selected firstVariant is actually inStock (otherwise leave variantId undefined or choose the first in-stock variant that matches the shown price/label), and replace the single-call stock = getVariantStockInfo(...) with this aggregated computation so id, inStock, lowStock, and stockCount reflect all variants rather than only firstVariant.apps/storefront/src/lib/medusa-products.ts-171-172 (1)
171-172:⚠️ Potential issue | 🟠 MajorFetch
variants.idanywhere you map toProduct.
mapMedusaToProduct()now emitsvariantId, but these field lists never requestvariants.id. Category/search/related/random/bought-together results will therefore map tovariantId: undefinedeven when a valid variant exists.Suggested fix
fields: - "id,handle,title,subtitle,metadata,thumbnail,*images.url,*variants.title,*variants.calculated_price,+variants.inventory_quantity,+variants.manage_inventory,*brand.*", + "id,handle,title,subtitle,metadata,thumbnail,*images.url,*variants.id,*variants.title,*variants.calculated_price,+variants.inventory_quantity,+variants.manage_inventory,*brand.*", @@ const PRODUCT_FIELDS = - "id,handle,title,subtitle,metadata,thumbnail,*images.url,*variants.title,*variants.calculated_price,+variants.inventory_quantity,+variants.manage_inventory,*brand.*"; + "id,handle,title,subtitle,metadata,thumbnail,*images.url,*variants.id,*variants.title,*variants.calculated_price,+variants.inventory_quantity,+variants.manage_inventory,*brand.*";Also applies to: 274-275
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/medusa-products.ts` around lines 171 - 172, The product field selections in apps/storefront/src/lib/medusa-products.ts are not requesting variants.id, so mapMedusaToProduct() emits variantId as undefined; update the fields strings (the one containing "id,handle,title,subtitle,metadata,thumbnail,*images.url,*variants.title,*variants.calculated_price,+variants.inventory_quantity,+variants.manage_inventory,*brand.*" and the similar fields around lines 274-275) to include variants.id (e.g., add *variants.id or +variants.id as appropriate) so that mapMedusaToProduct() can populate variantId correctly.apps/storefront/src/lib/payload-footer.ts-93-103 (1)
93-103:⚠️ Potential issue | 🟠 MajorValidate CMS-provided external URLs.
resolveLinkHref()currently returns any Payloadurlverbatim. Ajavascript:or other unsafe scheme here becomes a clickable XSS vector in the storefront footer.Suggested fix
+const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]); + +function isSafeExternalUrl(url: string): boolean { + try { + return SAFE_EXTERNAL_PROTOCOLS.has(new URL(url).protocol); + } catch { + return false; + } +} + function resolveLinkHref( locale: string, type: "internal" | "external" | null | undefined, page: PayloadPageRef | number | null | undefined, url: string | null | undefined ): string { const base = `/${locale}`; - if (type === "external" && url) return url; + if (type === "external" && url && isSafeExternalUrl(url)) return url; const path = getPathFromPage(page); return path ? `${base}/${path}` : base; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/payload-footer.ts` around lines 93 - 103, In resolveLinkHref, when type === "external" and using the url parameter, validate the CMS-provided URL before returning it: parse the url and allow only safe schemes (e.g., "http:", "https:", optionally "mailto:") and disallow dangerous schemes like "javascript:"; if the URL is invalid or uses a disallowed scheme, do not return it verbatim—fall back to the safe base path (constructed from locale) or an empty string. Update resolveLinkHref to perform this scheme whitelist check on the url input and only return url when it passes validation; otherwise use getPathFromPage(page) or the base value as the safe return.apps/storefront/src/app/api/products/route.ts-10-18 (1)
10-18:⚠️ Potential issue | 🟠 MajorCap
handlesbefore dispatching to Medusa.This endpoint is public, and
fetchProductsByHandles()fans out one upstream request per handle. A largehandlesquery can therefore create an unbounded burst of backend traffic from a single request.Suggested fix
import { fetchProductsByHandles } from "@/lib/medusa-products"; import { NextResponse } from "next/server"; +const MAX_HANDLES = 50; + export async function GET(req: Request) { try { const { searchParams } = new URL(req.url); const handlesParam = searchParams.get("handles"); const handles = handlesParam ? handlesParam.split(",").map((h) => h.trim()).filter(Boolean) : []; + if (handles.length > MAX_HANDLES) { + return NextResponse.json({ error: "Too many handles" }, { status: 400 }); + } if (handles.length === 0) { return NextResponse.json([]); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/products/route.ts` around lines 10 - 18, The public endpoint currently accepts an unbounded list via handlesParam and calls fetchProductsByHandles(handles), which can fan out many upstream requests; before dispatching, cap the handles array to a safe maximum (e.g., MAX_HANDLES constant) by taking the first N entries from handles and, if truncation occurs, optionally return a 400 or include a warning—update the logic around handlesParam/handles and use the capped array when calling fetchProductsByHandles to prevent unbounded backend bursts.apps/storefront/src/lib/payload-navigation.ts-120-137 (1)
120-137:⚠️ Potential issue | 🟠 MajorDrop invalid CMS links instead of routing them to home or
#.
pageis explicitly allowed to be a numeric relation id here. In that casegetPathFromPage()returns the same empty-string sentinel as the real home page, soresolveHref()sends the item to/${locale}; the other fallback becomes"#". Both cases make broken CMS data look like a valid nav item. Returnnullfor unresolved hrefs and filter those items out instead.Also applies to: 167-171
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/payload-navigation.ts` around lines 120 - 137, getPathFromPage currently treats numeric relation ids and missing paths the same as the "home" sentinel, causing resolveHref to return a valid-looking URL; change getPathFromPage (and any duplicate at lines ~167-171) to return null for unresolved pages (e.g., when page is a number or object without a valid path) instead of an empty string, and update resolveHref to return null when getPathFromPage yields null (and when type is not external and url is missing), so callers can filter out null hrefs rather than routing broken CMS links to the site root or "#".apps/storefront/src/lib/order-utils.ts-14-20 (1)
14-20:⚠️ Potential issue | 🟠 MajorThe amount heuristic misclassifies 10,000+ major-unit values.
Anything
>= 10_000returns early, soSESSION_MAJOR_MAX_EXCLUSIVEnever actually influences the result. A session value of10000stored in major units will later come back as100aftersessionAmountToMajor(). Either fix the branch order or stop guessing from magnitude and persist the unit explicitly.Also applies to: 41-46
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/order-utils.ts` around lines 14 - 20, The heuristic in sessionAmountToMajor misclassifies values equal to SESSION_MINOR_ALREADY_THRESHOLD (10_000) because the >= check short-circuits before SESSION_MAJOR_MAX_EXCLUSIVE is considered; fix by changing the branch logic so SESSION_MAJOR_MAX_EXCLUSIVE is evaluated before the "already minor" threshold or change the comparison to strictly > SESSION_MINOR_ALREADY_THRESHOLD, and/or (preferred long-term) stop guessing and persist the unit explicitly; update the checks involving SESSION_MINOR_ALREADY_THRESHOLD and SESSION_MAJOR_MAX_EXCLUSIVE (also at the other occurrence around lines 41-46) and adjust sessionAmountToMajor to use the corrected order/compare or read an explicit unit flag.apps/storefront/src/lib/payload-homepage.ts-146-150 (1)
146-150:⚠️ Potential issue | 🟠 Major
promo-barsblocks never enter the typed section union.
PromoBarsBlockis declared here but omitted fromHomepageSection, so"promo-bars"content falls off the typed API surface and won't participate in narrowing/exhaustiveness checks downstream.🧩 Minimal fix
export type HomepageSection = | HeroBlock | FeaturedProductsBlock | CategoriesBlock | TestimonialsBlock | ContentBlockBlock | ImageTextBreakoutBlock | NewsletterBlock | BlogCarouselBlock | BrandsBannerBlock | PromotionSliderBlock + | PromoBarsBlock | InspirationGuidesBlock | BrandSpotlightBlock | ServiceStripBlock | BulletColumnsBlock | ValueCardsBlock;Also applies to: 213-228
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/payload-homepage.ts` around lines 146 - 150, The HomepageSection discriminated union is missing PromoBarsBlock, so blocks with blockType "promo-bars" aren't part of the typed union; add PromoBarsBlock to the HomepageSection union (e.g., include "| PromoBarsBlock") and ensure any switch/narrowing logic or exported type aliases that enumerate block types include this new member (also check the similar missing-blocks area referenced around the other block declarations and add any omitted block types there); re-run TypeScript checks to confirm exhaustiveness and narrowing now cover "promo-bars".apps/storefront/src/app/[locale]/order-confirmation/OrderConfirmationComplete.tsx-46-51 (1)
46-51:⚠️ Potential issue | 🟠 MajorPersist a minimal confirmation payload, not the raw order object.
This serializes the entire order into script-readable storage. That likely includes address/contact fields, and it also locks the confirmation flow into brittle “guess the amount units” logic downstream. Store only the normalized fields the confirmation page needs, or just the order id and fetch the rest server-side.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/`[locale]/order-confirmation/OrderConfirmationComplete.tsx around lines 46 - 51, The code in OrderConfirmationComplete.tsx currently stores the entire res.order in sessionStorage; instead persist a minimal, normalized confirmation payload (or only the order id) instead of full order object to avoid saving PII and brittle unit assumptions. Update the sessionStorage.setItem call to JSON.stringify a small object containing only the fields the confirmation page needs (for example: { id: res.order.id, totalAmountCents: normalizeToCents(res.order.total, res.order.currency) OR total_cents from API, currency: res.order.currency, itemCount: res.order.items?.length }) or alternatively store only { id: res.order.id } and fetch the rest server-side; ensure you remove address/contact fields from the payload and keep the try/catch that swallows storage errors unchanged. Use the OrderConfirmationComplete component and the sessionStorage key `guapo_order_${res.order.id}` to locate where to replace the stored value.apps/storefront/src/lib/order-utils.ts-88-92 (1)
88-92:⚠️ Potential issue | 🟠 MajorThese camelCase fallbacks are currently no-ops.
Both expressions read the snake_case property twice, so
currencyCodeandshippingAddresspayloads are dropped even though this function claims to accept camelCase input.🛠️ Minimal fix
- currency_code: (o.currency_code ?? (o as Record<string, unknown>).currency_code) as string | undefined, + currency_code: (o.currency_code ?? (o as Record<string, unknown>).currencyCode) as string | undefined, ... - shipping_address: (o.shipping_address ?? (o as Record<string, unknown>).shipping_address) as Record<string, unknown> | undefined, + shipping_address: (o.shipping_address ?? (o as Record<string, unknown>).shippingAddress) as Record<string, unknown> | undefined,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/order-utils.ts` around lines 88 - 92, The mapping in order-utils.ts incorrectly falls back to the same snake_case key twice, dropping camelCase payloads; update the fallback expressions to read the camelCase properties instead (e.g. change (o.currency_code ?? (o as Record<string, unknown>).currency_code) to (o.currency_code ?? (o as Record<string, unknown>).currencyCode) as string|undefined and change (o.shipping_address ?? (o as Record<string, unknown>).shipping_address) to (o.shipping_address ?? (o as Record<string, unknown>).shippingAddress) as Record<string, unknown>|undefined) so the function accepts both snake_case and camelCase input for currency_code and shipping_address.apps/storefront/src/lib/payload-navigation.ts-214-239 (1)
214-239:⚠️ Potential issue | 🟠 MajorSkip both caches when draft is enabled.
When
draftis true, requests are cached via both the 60s in-memory cache and Next.jsrevalidate: 60, causing preview edits to appear stale for up to a minute. Draft reads should bypassgetCached()/setCache()and use non-cached fetch to ensure immediate preview updates.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/payload-navigation.ts` around lines 214 - 239, When options.draft is true, bypass both the in-memory cache and Next.js revalidation: skip calling getCached(key) and setCache(key, data), and remove or override the fetch option next: { revalidate: 60 } (use no-store or omit revalidate) so drafts are fetched uncached. Locate the draft variable and key creation, the getCached(...) call, the setCache(...) call, and the fetch(...) invocation (which currently has next: { revalidate: 60 }) and change the control flow so that when draft === true the function does not read/write the cache and the fetch uses a non-cached request.apps/storefront/src/app/api/search/route.ts-22-45 (1)
22-45:⚠️ Potential issue | 🟠 MajorTime-box the Payload lookup.
fetchArticles()fails open oncefetchrejects, but it never times out. Because Line 42 usesPromise.all, a hung CMS request can stall the whole/api/searchresponse instead of degrading to article-less results.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/search/route.ts` around lines 22 - 45, fetchArticles currently awaits fetch with no timeout, so a hung CMS call can stall Promise.all; modify fetchArticles (the async function named fetchArticles) to use an AbortController and a short timeout (e.g., 2–5s) that aborts the fetch if exceeded, attach the controller.signal to fetch, clear the timeout on success, and catch AbortError to return { docs: [] } so Promise.all can proceed and the endpoint degrades gracefully to article-less results.apps/storefront/src/app/api/cart/line-item/route.ts-41-44 (1)
41-44:⚠️ Potential issue | 🟠 MajorReturn a generic body for unexpected 500s.
This currently echoes
err.messagefor every non-inventory failure. On a public cart route, that can leak Medusa/internal error details to the client.Proposed fix
} catch (err) { const message = err instanceof Error ? err.message : "Failed to update line item"; - const status = isMedusaInventoryCartErrorMessage(message) ? 400 : 500; - return NextResponse.json({ error: message }, { status }); + if (isMedusaInventoryCartErrorMessage(message)) { + return NextResponse.json({ error: message }, { status: 400 }); + } + return NextResponse.json({ error: "Failed to update line item" }, { status: 500 }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/cart/line-item/route.ts` around lines 41 - 44, The catch currently returns err.message for all errors which can leak internal details; update the catch in the route handler so you derive status via isMedusaInventoryCartErrorMessage(errMessage) but only return the original err.message for 400 inventory errors, and return a generic body (e.g. { error: "An unexpected error occurred" }) for 500 responses; keep logging the original err (console.error or processLogger) before returning so the server retains detail. Use the existing identifiers err, message/status, isMedusaInventoryCartErrorMessage, and NextResponse.json when making this change.apps/storefront/src/app/api/cart/line-item/route.ts-14-24 (1)
14-24:⚠️ Potential issue | 🟠 MajorValidate
quantitybefore normalizing it.The cast on Line 14 does not validate runtime JSON. A payload like
{ "quantity": "abc" }makesqtybecomeNaN, skips the cap check, and still reachesupdateLineIteminstead of returning a 400.Proposed fix
const body = (await req.json()) as { lineItemId?: string; - quantity?: number; + quantity?: unknown; metadata?: Record<string, unknown>; }; const lineItemId = body.lineItemId; const quantity = body.quantity; if (typeof lineItemId !== "string" || !lineItemId) { return NextResponse.json({ error: "Missing lineItemId" }, { status: 400 }); } - const qty = Math.max(1, Math.floor(Number(quantity ?? 1))); + const numericQuantity = quantity == null ? 1 : Number(quantity); + if (!Number.isFinite(numericQuantity)) { + return NextResponse.json({ error: "Invalid quantity" }, { status: 400 }); + } + const qty = Math.max(1, Math.floor(numericQuantity));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/cart/line-item/route.ts` around lines 14 - 24, The request body fields (body.quantity) aren't runtime-validated before normalizing, so converting non-numeric values yields NaN and allows invalid requests through; update the validation around body, quantity and qty so that after extracting quantity you verify it's a finite numeric value (e.g., typeof quantity === "number" and Number.isFinite(quantity) or parse and validate a numeric string) and return NextResponse.json({ error: "Invalid quantity" }, { status: 400 }) for bad input; then compute qty = Math.max(1, Math.floor(Number(quantity))) and proceed to call updateLineItem only when qty is valid; refer to body, lineItemId, quantity, qty, updateLineItem and NextResponse.json when making the change.apps/storefront/src/app/api/cart/free-shipping/route.ts-58-63 (1)
58-63:⚠️ Potential issue | 🟠 MajorDon't relay raw upstream error bodies to clients.
Line 59 forwards Medusa's response body verbatim. That can expose internal diagnostic text or HTML error pages through a public API route.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/cart/free-shipping/route.ts` around lines 58 - 63, The handler in route.ts currently takes the upstream response body (errText) and returns it directly to clients; instead, stop relaying raw upstream bodies by returning a sanitized error payload (e.g., { message: "Free shipping status failed", code: "UPSTREAM_ERROR" } with the original res.status) and log the actual upstream body internally for diagnostics; locate the failure branch around the variable res and the NextResponse.json call in the route handler, remove errText from the client-facing JSON, and send errText to an internal logger (console.error or processLogger) rather than including it in the response.apps/storefront/src/app/[locale]/wishlist/WishlistPageContent.tsx-39-48 (1)
39-48:⚠️ Potential issue | 🟠 MajorDon't key the wishlist hydration guard only on
customer.id.If auth state exposes
customer.idbeforecustomer.metadata, Line 42 marks the customer as loaded on the first pass and later metadata updates for the same user are skipped. That can preventwishlist_handlesfrom ever being merged into the session.🔧 Suggested fix
const loadedCustomerRef = useRef<string | null>(null); useEffect(() => { - if (!isAuthenticated || !customer?.id || loadedCustomerRef.current === customer.id) return; - loadedCustomerRef.current = customer.id; + if (!isAuthenticated || !customer?.id) return; const meta = customer.metadata as Record<string, unknown> | undefined; const handles = Array.isArray(meta?.[WISHLIST_METADATA_KEY]) ? (meta[WISHLIST_METADATA_KEY] as string[]) : []; + const loadedKey = `${customer.id}:${handles.join(",")}`; + if (loadedCustomerRef.current === loadedKey) return; + loadedCustomerRef.current = loadedKey; mergeIds(handles); }, [isAuthenticated, customer?.id, customer?.metadata, mergeIds]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/`[locale]/wishlist/WishlistPageContent.tsx around lines 39 - 48, The useEffect guard marks a customer loaded based only on customer.id (loadedCustomerRef) causing metadata updates to be ignored; change the guard to also consider customer.metadata (or a derived key from WISHLIST_METADATA_KEY) so we only set loadedCustomerRef.current after metadata has been processed, and trigger mergeIds when metadata containing WISHLIST_METADATA_KEY becomes available; update the effect dependencies and the condition in the effect surrounding loadedCustomerRef/current, customer?.id, customer?.metadata and call mergeIds when meta[WISHLIST_METADATA_KEY] is present to ensure wishlist_handles are merged into the session.apps/storefront/src/app/[locale]/login/LoginForm.tsx-83-86 (1)
83-86:⚠️ Potential issue | 🟠 MajorPersist the sanitized return URL for the Google flow.
The email path already uses
getSafeReturnUrl, but Lines 83-86 store the rawreturnUrlinsessionStorage. That makes the callback flow depend on a second sanitization step instead of enforcing the safe path here.🔧 Suggested fix
- const currentReturnUrl = returnUrl || getCurrentReturnUrl(`/${locale}/account`); + const currentReturnUrl = getSafeReturnUrl( + returnUrl ?? getCurrentReturnUrl(`/${locale}/account`), + defaultDestination + ); if (typeof window !== "undefined") { sessionStorage.setItem("guapo_google_return_url", currentReturnUrl); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/`[locale]/login/LoginForm.tsx around lines 83 - 86, The code stores the raw returnUrl into sessionStorage for the Google flow; instead ensure you persist a sanitized return URL by using getSafeReturnUrl instead of getCurrentReturnUrl/get raw returnUrl. Update the logic around currentReturnUrl (and where sessionStorage.setItem("guapo_google_return_url", ... ) is called) to compute currentReturnUrl via getSafeReturnUrl(returnUrl, `/${locale}/account`) (preserving the typeof window check) so the value written to sessionStorage is already validated.apps/storefront/src/lib/cart.ts-190-196 (1)
190-196:⚠️ Potential issue | 🟠 MajorDon't use aggregate
discount_totalas the subscription readiness signal.This cart now supports non-subscription discounts too. If a coupon is already applied, Line 195 becomes
truebefore the subscription adjustment lands, so the poll can exit early on enable and wait the full timeout on disable. Poll a subscription-specific signal on the updated line item instead.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/cart.ts` around lines 190 - 196, The poll currently uses aggregate cart.discount_total (in the loop near expectDiscount and getCart) which can be set by unrelated coupons; change the readiness check to inspect the subscription-specific line item instead: after fetching cart with getCart(), locate the subscription line item in cart.items (e.g., by product/variant id or a subscription flag on the line item) and test a subscription-specific field (for example lineItem.discount_total or a metadata flag like lineItem.metadata.subscription_discount_applied) against expectDiscount; break the loop only when that specific line item reflects the subscription discount change rather than using cart.discount_total.apps/storefront/src/lib/cart.ts-134-137 (1)
134-137:⚠️ Potential issue | 🟠 MajorReject invalid quantities instead of coercing them.
Math.max(1, Math.floor(Number(quantity)))still letsNaNthrough (JSON.stringifyturns it intonull) and silently floors decimals. That weakens the “integer >= 1” contract and pushes bad input down to the Medusa API.Suggested fix
- const quantityInt = Math.max(1, Math.floor(Number(quantity))); + const quantityInt = Number(quantity); + if (!Number.isInteger(quantityInt) || quantityInt < 1) { + throw new Error("Quantity must be an integer greater than 0"); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/lib/cart.ts` around lines 134 - 137, The current code coerces bad quantities with Math.max/Math.floor which allows NaN (becomes null) and silently floors decimals; instead, validate the incoming quantity before building body: parse it (e.g., const q = Number(quantity)), then reject if !Number.isInteger(q) or q < 1 or !Number.isFinite(q) (throw an Error or return a rejected Promise consistent with the surrounding function), and only set body.quantity = q when valid; update references to quantityInt/body to use the validated integer or short-circuit on invalid input.apps/storefront/src/app/[locale]/[...path]/page.tsx-24-38 (1)
24-38:⚠️ Potential issue | 🟠 MajorKeep metadata resolution in sync with the route guard and draft mode.
The page component rejects empty paths and
/home, and it can render draft content when?draft=1|trueis present.generateMetadata()ignores those cases, so preview URLs can still emit published metadata withrobots.index = true.Suggested fix
-export async function generateMetadata({ params }: PayloadPageProps): Promise<Metadata> { +export async function generateMetadata({ params, searchParams }: PayloadPageProps): Promise<Metadata> { const { locale, path: pathSegments } = await params; + const { draft: draftParam } = await searchParams; + const draft = draftParam === "1" || draftParam === "true"; const pathString = pathSegments?.length ? pathSegments.join("/") : ""; - if (!pathString) return { title: "Page" }; + if (!pathString || pathString === "home") { + return { title: "Page", robots: { index: !draft } }; + } - const page = await fetchPageByPath(pathString, locale as Locale); + const page = await fetchPageByPath(pathString, locale as Locale, { draft }); if (!page) return { title: "Page" }; const title = (page.meta?.title as string) ?? page.title ?? "Page"; const description = page.meta?.description as string | undefined; return { title, description: description ?? undefined, - robots: { index: true }, + robots: { index: !draft }, }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/`[locale]/[...path]/page.tsx around lines 24 - 38, generateMetadata currently ignores the route guard and draft preview state; update its signature to accept searchParams ({ params, searchParams }) and mirror the page component behavior: if pathString is empty or equals "home" return the default metadata but mark robots.index = false; detect draft by checking searchParams.draft for "1" or "true" and, when draft is present, fetch the page in preview/draft mode via fetchPageByPath (pass any existing draft/preview flag the helper supports) and set robots.index = false for draft responses (or if fetchPageByPath doesn’t support draft, still set robots.index = false when draft param is present) so preview URLs do not emit published indexable metadata.apps/storefront/src/app/[locale]/order-confirmation/OrderConfirmationContent.tsx-47-68 (1)
47-68:⚠️ Potential issue | 🟠 MajorKeep
loadingtrue until the fallback fetch actually settles.Line 68 clears the spinner immediately after the request is started, so slow responses briefly render the error state before
setOrder()has a chance to run.Suggested fix
if (!done) { const base = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || ""; const key = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || ""; if (base && typeof window !== "undefined") { fetch(`${base.replace(/\/$/, "")}/store/orders/${encodeURIComponent(orderId)}`, { credentials: "include", headers: { Accept: "application/json", ...(key && { "x-publishable-api-key": key }), }, }) .then((res) => (res.ok ? res.json() : null)) .then((data: { order?: unknown } | null) => { if (data?.order) { const normalized = normalizeOrder(data.order); if (normalized) setOrder(normalized); } }) .catch(() => {}) + .finally(() => setLoading(false)); + return; } } setLoading(false);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/`[locale]/order-confirmation/OrderConfirmationContent.tsx around lines 47 - 68, The spinner is cleared immediately by the unconditional setLoading(false) after starting the fetch; move the setLoading(false) call into the fetch promise chain (e.g., add a .finally(() => setLoading(false))) so loading remains true until the request settles, and ensure you only call .finally when the fetch is actually executed inside the if (!done) block (or use async/await and setLoading(false) in a try/catch/finally around normalizeOrder/setOrder). Reference functions/vars: orderId, normalizeOrder, setOrder, setLoading, and the fetch promise chain.apps/storefront/src/app/[locale]/[...path]/page.tsx-82-85 (1)
82-85:⚠️ Potential issue | 🟠 MajorPrevent protocol-based XSS injection in URLs.
The
escapeHtml()function inlexicalToHtmlescapes HTML entities (&,<,>,"), preventing direct HTML injection. However, it does not prevent protocol-based attacks:javascript:alert(1)anddata:URLs pass through unescaped. If Lexical content allows malicious URLs in link nodes, these will execute when users click links. Additionally, the default case for unknown node types returns unescaped content, creating a gap if Payload adds custom node types or if JSON validation is bypassed.Use
url-parseor similar to validate link protocols are safe (http/https), or apply DOMPurify after conversion to strip script-based protocols.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/`[locale]/[...path]/page.tsx around lines 82 - 85, The HTML output from lexicalToHtml uses escapeHtml but still allows protocol-based XSS in links and returns unescaped content for unknown nodes; update lexicalToHtml to validate/normalize all link hrefs (the link node handler that produces hrefs) by parsing each URL (e.g., with url-parse or URL) and only allowing safe protocols (http, https, mailto, tel) or else drop/neutralize the href, and change the default/unknown node handling to return escaped text rather than raw content; additionally, as a final hardening step run the produced contentHtml through a sanitizer such as DOMPurify before assigning to dangerouslySetInnerHTML to strip any remaining script/data: or javascript: protocols.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: f30d040e-ead2-4838-bcc1-4f5f68c7e1e0
📒 Files selected for processing (89)
apps/storefront/next.config.tsapps/storefront/package.jsonapps/storefront/src/app/[locale]/[...path]/page.tsxapps/storefront/src/app/[locale]/account/addresses/page.tsxapps/storefront/src/app/[locale]/account/layout.tsxapps/storefront/src/app/[locale]/account/orders/[id]/page.tsxapps/storefront/src/app/[locale]/account/orders/page.tsxapps/storefront/src/app/[locale]/account/page.tsxapps/storefront/src/app/[locale]/account/profile/page.tsxapps/storefront/src/app/[locale]/account/subscriptions/[id]/page.tsxapps/storefront/src/app/[locale]/account/subscriptions/page.tsxapps/storefront/src/app/[locale]/auth/google/callback/page.tsxapps/storefront/src/app/[locale]/blog/[slug]/page.tsxapps/storefront/src/app/[locale]/blog/page.tsxapps/storefront/src/app/[locale]/brands/[handle]/page.tsxapps/storefront/src/app/[locale]/cart/page.tsxapps/storefront/src/app/[locale]/categories/[handle]/page.tsxapps/storefront/src/app/[locale]/checkout/page.tsxapps/storefront/src/app/[locale]/error.tsxapps/storefront/src/app/[locale]/layout.tsxapps/storefront/src/app/[locale]/login/AuthRequiredBanner.tsxapps/storefront/src/app/[locale]/login/LoginForm.tsxapps/storefront/src/app/[locale]/login/page.tsxapps/storefront/src/app/[locale]/order-confirmation/OrderConfirmationComplete.tsxapps/storefront/src/app/[locale]/order-confirmation/OrderConfirmationContent.tsxapps/storefront/src/app/[locale]/order-confirmation/[orderId]/page.tsxapps/storefront/src/app/[locale]/page.tsxapps/storefront/src/app/[locale]/policies/cookies/page.tsxapps/storefront/src/app/[locale]/products/[handle]/page.tsxapps/storefront/src/app/[locale]/register/RegisterForm.tsxapps/storefront/src/app/[locale]/register/page.tsxapps/storefront/src/app/[locale]/search/page.tsxapps/storefront/src/app/[locale]/wishlist/WishlistPageContent.tsxapps/storefront/src/app/[locale]/wishlist/page.tsxapps/storefront/src/app/api/cart/free-shipping/route.tsapps/storefront/src/app/api/cart/line-item/route.tsapps/storefront/src/app/api/cart/route.tsapps/storefront/src/app/api/geocode/route.tsapps/storefront/src/app/api/newsletter/route.tsapps/storefront/src/app/api/product-reviews/route.tsapps/storefront/src/app/api/products/route.tsapps/storefront/src/app/api/search/route.tsapps/storefront/src/app/global-error.tsxapps/storefront/src/app/globals.cssapps/storefront/src/contexts/AddToCartModalContext.tsxapps/storefront/src/contexts/AuthContext.tsxapps/storefront/src/contexts/CartContext.tsxapps/storefront/src/contexts/CheckoutCartContext.tsxapps/storefront/src/contexts/WishlistContext.tsxapps/storefront/src/hooks/use-media-query.tsapps/storefront/src/hooks/useFreeShippingStatus.tsapps/storefront/src/i18n/dictionaries.tsapps/storefront/src/i18n/dictionaries/da.jsonapps/storefront/src/i18n/dictionaries/en.jsonapps/storefront/src/lib/account-status-labels.tsapps/storefront/src/lib/account-summary.tsapps/storefront/src/lib/auth-utils.tsapps/storefront/src/lib/cart-data.tsapps/storefront/src/lib/cart-display.tsapps/storefront/src/lib/cart-errors.tsapps/storefront/src/lib/cart.tsapps/storefront/src/lib/fetch-client-cart.tsapps/storefront/src/lib/format.tsapps/storefront/src/lib/free-shipping-config.server.tsapps/storefront/src/lib/free-shipping-status.tsapps/storefront/src/lib/home-mock.tsapps/storefront/src/lib/homepage-primary-hero.tsapps/storefront/src/lib/lexical-to-html.tsapps/storefront/src/lib/medusa-product-reviews.tsapps/storefront/src/lib/medusa-products.tsapps/storefront/src/lib/medusa.tsapps/storefront/src/lib/order-utils.tsapps/storefront/src/lib/orders.tsapps/storefront/src/lib/payload-articles.tsapps/storefront/src/lib/payload-footer.tsapps/storefront/src/lib/payload-homepage.tsapps/storefront/src/lib/payload-media-url.tsapps/storefront/src/lib/payload-navigation.tsapps/storefront/src/lib/pickup-points.tsapps/storefront/src/lib/product-inventory.tsapps/storefront/src/lib/resolve-homepage-data.tsapps/storefront/src/lib/review-display-name.tsapps/storefront/src/lib/server-cache.tsapps/storefront/src/lib/shell-path.tsapps/storefront/src/lib/shipping-config.tsapps/storefront/src/lib/subscription-config.tsapps/storefront/src/lib/utils.tsapps/storefront/src/middleware.tsapps/storefront/tsconfig.json
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.41.1)
apps/storefront/src/app/[locale]/blog/[slug]/page.tsx
[warning] 138-138: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
apps/storefront/src/app/[locale]/[...path]/page.tsx
[warning] 83-83: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.4.9)
apps/storefront/src/lib/lexical-to-html.ts
[error] 40-40: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.
(lint/correctness/noSwitchDeclarations)
🪛 GitHub Actions: CI
apps/storefront/package.json
[error] 1-1: pnpm install --frozen-lockfile failed: ERR_PNPM_OUTDATED_LOCKFILE. pnpm-lock.yaml is not up to date with apps/storefront/package.json. Specifiers in the lockfile do not match package.json.
🪛 Stylelint (17.5.0)
apps/storefront/src/app/globals.css
[error] 5-5: Unexpected unknown at-rule "@custom-variant" (scss/at-rule-no-unknown)
(scss/at-rule-no-unknown)
[error] 425-425: Expected empty line before declaration (declaration-empty-line-before)
(declaration-empty-line-before)
[error] 341-341: Unexpected deprecated property "clip" (property-no-deprecated)
(property-no-deprecated)
[error] 355-355: Unexpected deprecated property "clip" (property-no-deprecated)
(property-no-deprecated)
Address critical PR #20 findings by adding request timeouts for free-shipping upstream calls, enforcing safe sign-out redirects, sanitizing rich-text link protocols, restoring visible keyboard focus states, and syncing pnpm lockfile with storefront dependencies. Made-with: Cursor
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
apps/storefront/src/app/api/cart/free-shipping/route.ts (1)
77-81: Keep!cartIdresponses shape-stable on thrown upstream errors.For the no-cart path, Line 45 already returns a typed free-shipping payload on non-OK upstream responses. But thrown errors (timeout/network) fall through to Line 77 and return a different
{ message, code }envelope. Consider returning the same fallback payload for exceptions in the!cartIdbranch too.Proposed refactor
export async function GET() { + const noCartFallback = { + threshold: null, + cart_total: 0, + remaining: null, + qualifies: false, + enabled: true, + promotion_code: "FREESHIPPING", + }; + try { const cartId = await getCartId(); @@ if (!cartId) { - const cfgController = new AbortController(); - const cfgTimeoutId = setTimeout(() => cfgController.abort(), UPSTREAM_TIMEOUT_MS); - const cfgRes = await fetch(`${MEDUSA_URL}/store/free-shipping-config`, { - headers, - cache: "no-store", - signal: cfgController.signal, - }).finally(() => clearTimeout(cfgTimeoutId)); - if (cfgRes.ok) { - const cfg = (await cfgRes.json()) as { - threshold?: number; - enabled?: boolean; - promotion_code?: string; - }; - const threshold = typeof cfg.threshold === "number" ? cfg.threshold : 499; - return NextResponse.json({ - threshold, - cart_total: 0, - remaining: threshold, - qualifies: false, - enabled: cfg.enabled !== false, - promotion_code: cfg.promotion_code ?? "FREESHIPPING", - }); - } - return NextResponse.json( - { - threshold: null, - cart_total: 0, - remaining: null, - qualifies: false, - enabled: true, - promotion_code: "FREESHIPPING", - }, - { status: 200 } - ); + try { + const cfgController = new AbortController(); + const cfgTimeoutId = setTimeout(() => cfgController.abort(), UPSTREAM_TIMEOUT_MS); + const cfgRes = await fetch(`${MEDUSA_URL}/store/free-shipping-config`, { + headers, + cache: "no-store", + signal: cfgController.signal, + }).finally(() => clearTimeout(cfgTimeoutId)); + + if (cfgRes.ok) { + const cfg = (await cfgRes.json()) as { + threshold?: number; + enabled?: boolean; + promotion_code?: string; + }; + const threshold = typeof cfg.threshold === "number" ? cfg.threshold : 499; + return NextResponse.json({ + threshold, + cart_total: 0, + remaining: threshold, + qualifies: false, + enabled: cfg.enabled !== false, + promotion_code: cfg.promotion_code ?? "FREESHIPPING", + }); + } + } catch { + // swallow upstream timeout/network errors for no-cart path + } + return NextResponse.json(noCartFallback, { status: 200 }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/app/api/cart/free-shipping/route.ts` around lines 77 - 81, The catch at the end of route.ts returns a different `{ message, code }` envelope on upstream throws; change it so the thrown-error path returns the same typed free-shipping fallback used in the `!cartId` branch/non-OK upstream path (the same object shape or the same FreeShippingResponse literal used at line 45) and return it via NextResponse.json so consumers get a shape-stable response; update the catch to reuse that fallback payload (or a shared constant) instead of the `{ message, code }` object and keep the same status semantics as the existing `!cartId` fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/storefront/src/app/globals.css`:
- Around line 426-429: The body rule inside the `@layer` base block violates your
stylelint declaration-empty-line-before rule; open the `@layer` base { ... body {
... } } block and add the required empty line before the declaration at color:
var(--text-primary) (i.e., ensure there is a blank line between declarations in
the body selector), then re-run stylelint to confirm the
declaration-empty-line-before rule is satisfied for the body rule.
- Around line 380-390: The rules for .search-modal-form and .search-modal-input
currently remove all focus affordances with !important which breaks keyboard
accessibility; replace those blanket removals by removing the !important
declarations and instead add a clear, visible focus style for
.search-modal-input:focus and .search-modal-input:focus-visible (for example a
high-contrast outline or box-shadow) while keeping any necessary visual
adjustments for .search-modal-form, ensuring the focus indication is not
suppressed and remains keyboard-visible.
- Around line 339-367: Replace the deprecated clip usage in the .skip-link
rules: in .skip-link replace "clip: rect(0, 0, 0, 0);" with a modern equivalent
such as "clip-path: inset(50%);" (keeping the other hiding properties) and in
.skip-link:focus replace "clip: auto;" with "clip-path: none;". Update both
occurrences where "clip" is used and ensure no leftover deprecated property
remains in the .skip-link and .skip-link:focus blocks.
- Line 5: Stylelint is flagging Tailwind v4's `@custom-variant` at-rule (seen in
the CSS snippet as "@custom-variant dark (&:is(.dark *));"); update the
stylelint config (in .stylelintrc.json) to allow Tailwind at-rules by adding the
at-rule name(s) to "ignoreAtRules" (include "custom-variant" and other Tailwind
directives you use such as "tailwind", "apply", "variants", "responsive",
"layer", "screen") so stylelint no longer reports those as unknown.
---
Nitpick comments:
In `@apps/storefront/src/app/api/cart/free-shipping/route.ts`:
- Around line 77-81: The catch at the end of route.ts returns a different `{
message, code }` envelope on upstream throws; change it so the thrown-error path
returns the same typed free-shipping fallback used in the `!cartId`
branch/non-OK upstream path (the same object shape or the same
FreeShippingResponse literal used at line 45) and return it via
NextResponse.json so consumers get a shape-stable response; update the catch to
reuse that fallback payload (or a shared constant) instead of the `{ message,
code }` object and keep the same status semantics as the existing `!cartId`
fallback.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 67cb0881-a302-48ab-836b-5a6c19ca1509
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (5)
apps/storefront/src/app/api/cart/free-shipping/route.tsapps/storefront/src/app/globals.cssapps/storefront/src/contexts/AuthContext.tsxapps/storefront/src/lib/free-shipping-config.server.tsapps/storefront/src/lib/lexical-to-html.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- apps/storefront/src/contexts/AuthContext.tsx
- apps/storefront/src/lib/free-shipping-config.server.ts
- apps/storefront/src/lib/lexical-to-html.ts
📜 Review details
🧰 Additional context used
🪛 Stylelint (17.5.0)
apps/storefront/src/app/globals.css
[error] 5-5: Unexpected unknown at-rule "@custom-variant" (scss/at-rule-no-unknown)
(scss/at-rule-no-unknown)
[error] 428-428: Expected empty line before declaration (declaration-empty-line-before)
(declaration-empty-line-before)
[error] 346-346: Unexpected deprecated property "clip" (property-no-deprecated)
(property-no-deprecated)
[error] 360-360: Unexpected deprecated property "clip" (property-no-deprecated)
(property-no-deprecated)
🔇 Additional comments (2)
apps/storefront/src/app/api/cart/free-shipping/route.ts (1)
22-28: Good resilience improvement: both upstream calls are now timeout-bounded.Nice fix. Both request paths now have abort signals plus timer cleanup in
finally, which removes the previous hanging-request risk.Also applies to: 58-63
apps/storefront/src/app/globals.css (1)
314-336: Nice reduced-motion handling for animations.Good inclusion of
prefers-reduced-motionfallbacks for both marquee and drawer animations.
- Sync apps/storefront/src/components from M11 source branch so @/components imports resolve and typecheck passes. - globals.css: clip-path for skip-link, search-modal :focus-visible ring, body spacing for stylelint. - free-shipping API: degraded payload constant, inner try/catch for no-cart upstream, outer catch returns degraded shape when cartId is null. - Add .stylelintrc.json ignoring Tailwind v4 at-rules for CodeRabbit/stylelint. Made-with: Cursor
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
🟠 Major comments (33)
apps/storefront/src/components/AccountLayout.tsx-165-210 (1)
165-210:⚠️ Potential issue | 🟠 MajorMissing modal accessibility attributes and focus management.
The mobile bottom sheet functions as a modal dialog but lacks essential ARIA attributes and focus management:
- Missing ARIA attributes: The sheet container should have
role="dialog"andaria-modal="true"to announce it properly to screen readers.- No focus trap: Keyboard users can tab out of the open menu to elements behind the backdrop.
- No focus management: Focus should move into the dialog when opened and return to the trigger when closed.
🛠️ Proposed fix for ARIA attributes
<div className={cn( "fixed inset-x-0 bottom-0 z-50 rounded-t-2xl border-t border-border bg-card shadow-xl transition-transform duration-200 ease-out md:hidden", menuOpen ? "translate-y-0" : "translate-y-full" )} + role="dialog" + aria-modal="true" + aria-labelledby="account-menu-title" > <div className="flex max-h-[78vh] flex-col"> <div className="flex items-center justify-between border-b border-border px-4 py-3"> - <span className="font-semibold text-foreground">{labels.accountTitle}</span> + <span id="account-menu-title" className="font-semibold text-foreground">{labels.accountTitle}</span>For a complete solution, consider using a focus-trap library (e.g.,
focus-trap-react) or implementing focus management with refs to move focus to the close button on open and back to the trigger button on close.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/AccountLayout.tsx` around lines 165 - 210, Add proper modal semantics and focus management for the mobile bottom-sheet: give the sheet container (the div using cn(...) with translate-y classes) role="dialog" and aria-modal="true" and ensure the backdrop div remains aria-hidden; when menuOpen becomes true move focus into the dialog (e.g., focus the close button referenced by the X button/closeMenuLabel) and when it closes restore focus to the trigger that toggles setMenuOpen; prevent keyboard users from tabbing out of the dialog by integrating a focus trap (use focus-trap-react around the dialog content or implement a simple trap with refs and keydown handling inside the component) and ensure NavLinks onLinkClick calls setMenuOpen(false) also returns focus to the trigger.apps/storefront/src/components/account/ProfileForm.tsx-122-127 (1)
122-127:⚠️ Potential issue | 🟠 MajorAdd ARIA live region attributes for success/error feedback.
The feedback status element is currently not announced by screen readers. Add
role,aria-live, andaria-atomicattributes to announce form submission results to users relying on assistive technology.Suggested patch
- {feedback && ( - <span className={`inline-flex items-center gap-1.5 text-sm font-medium ${feedback.type === "success" ? "text-success" : "text-destructive"}`}> + {feedback && ( + <span + role={feedback.type === "error" ? "alert" : "status"} + aria-live={feedback.type === "error" ? "assertive" : "polite"} + aria-atomic="true" + className={`inline-flex items-center gap-1.5 text-sm font-medium ${feedback.type === "success" ? "text-success" : "text-destructive"}`} + > {feedback.type === "success" && <Check className="h-4 w-4" />} {feedback.msg} </span> )}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/account/ProfileForm.tsx` around lines 122 - 127, The feedback <span> in ProfileForm.tsx is not announced by screen readers; update the element rendered where "feedback" is used inside the ProfileForm component to include accessibility live-region attributes: add role="status" (or role="alert" for immediate errors), aria-live set to "polite" for success and "assertive" for errors, and aria-atomic="true" so the full message is announced; adjust the conditional that checks feedback.type to set the appropriate aria-live/role values while keeping the existing classes and icons.apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx-105-109 (1)
105-109:⚠️ Potential issue | 🟠 MajorConfigure
remotePatternsfor Medusa image URLs before usingnext/image.The suggestion to use
next/imageis sound—the parent container has the requiredposition: relativeand fixed dimensions for thefillprop. However, the currentremotePatternsinnext.config.jsonly allows Supabase URLs. Sinceitem.thumbnailcomes from the Medusa backend API, you must first add the Medusa domain toremotePatternsor the images will fail to load when usingnext/image.Add the Medusa domain to
images.remotePatterns:images: { remotePatterns: [ { protocol: "https", hostname: `${supabaseProjectId}.supabase.co`, pathname: "/storage/v1/object/public/**", }, { protocol: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL?.startsWith("https") ? "https" : "http", hostname: new URL(process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL || "http://localhost:9000").hostname, }, ], }Then apply the refactor to use
next/imagewith thefillprop.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx` around lines 105 - 109, The image currently rendered with <img src={item.thumbnail}> will break if you switch to next/image because next.config.js only allows Supabase; update images.remotePatterns to include your Medusa backend hostname (derived from NEXT_PUBLIC_MEDUSA_BACKEND_URL) so Medusa-served thumbnails are permitted, then replace the <img> in CheckoutOrderSummary (where item.thumbnail and item.title are used) with next/image using the fill prop inside the existing relative/fixed parent container; ensure the remotePatterns entry uses the correct protocol and hostname extraction (e.g., new URL(process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL).hostname) so Medusa URLs load successfully.apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts-60-73 (1)
60-73:⚠️ Potential issue | 🟠 MajorIgnore outdated pickup lookup responses.
Both fetch paths only check
mountedRef, so a slower response for an old address can still overwrite newer results or repopulate the sheet after it closes. Track a request token or latest-query snapshot and drop responses that are no longer current.Also applies to: 95-108
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts` around lines 60 - 73, The async fetch flow in usePickupPointSheetSearch (the fetchAllPickupPoints → enrichWithDistance promise chain) only checks mountedRef and can let stale responses overwrite newer state; add a request token or “currentQuery” snapshot (e.g., incrementing requestId or storing the current query string) before each fetch, capture it in the then/finally handlers and bail out if it doesn't match the latest token, and apply the same guard to the other fetch path (the block around enrichWithDistance at lines 95–108) so setPickupPoints, setSelectedPoint and setPickupLoading only run for the latest request.apps/storefront/src/components/checkout/HeaderBackButton.tsx-37-46 (1)
37-46:⚠️ Potential issue | 🟠 MajorKeep an accessible name on the back control at mobile breakpoints.
On small screens the text label is
display:none, so both variants become icon-only and lose their accessible name. Addaria-label={dict.checkout.previousStep}or keep a screen-reader-only label on both controls.♿ Suggested fix
- <Link href={`/${locale}/cart`} className={className}> + <Link + href={`/${locale}/cart`} + className={className} + aria-label={dict.checkout.previousStep} + > <ArrowLeft className="h-4 w-4" /> <span className="hidden sm:inline">{dict.checkout.previousStep}</span> </Link> ) : ( - <button type="button" onClick={onBack} className={className}> + <button + type="button" + onClick={onBack} + className={className} + aria-label={dict.checkout.previousStep} + > <ArrowLeft className="h-4 w-4" /> <span className="hidden sm:inline">{dict.checkout.previousStep}</span> </button>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/HeaderBackButton.tsx` around lines 37 - 46, The back control loses its accessible name at mobile sizes because the visible text (dict.checkout.previousStep) is hidden; update the HeaderBackButton rendering so both the Link (when currentStep === 1) and the button (when currentStep !== 1) include an accessible name—e.g., add aria-label={dict.checkout.previousStep} or include a screen-reader-only element—on the Link and the button (the elements using className, ArrowLeft, onBack and dict.checkout.previousStep) so the icon-only control remains accessible.apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts-57-64 (1)
57-64:⚠️ Potential issue | 🟠 MajorDon't fall back to street text for
zipcode.When
address1is present butpostalCodeis empty,combined.slice(0, 4)can send values like"Main"aszipcode, which will produce a bogus upstream lookup on sheet open. Reuse the digit-only fallback from the debounced path and skip the fetch when no zipcode can be derived.🔧 Suggested fix
- const zipForSearch = - zip.length >= 3 ? zip : extractZipcodeFromAddress(combined) || combined.slice(0, 4); + const zipForSearch = + zip.length >= 3 + ? zip + : extractZipcodeFromAddress(combined) || combined.replace(/\D/g, "").slice(0, 4);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts` around lines 57 - 64, The current logic in usePickupPointSheetSearch builds zipForSearch using combined.slice(0, 4) which can produce non-numeric strings (e.g., "Main") and trigger bogus fetches; change zipForSearch to reuse the digit-only fallback used in the debounced path by extracting only numeric sequences (e.g., via extractZipcodeFromAddress or a regex to find digit sequences of length >=3) and set zipForSearch to undefined if no numeric zipcode can be derived, then skip calling fetchAllPickupPoints when zipForSearch is falsy; update references to zipForSearch and the fetchAllPickupPoints call so only numeric zip codes trigger the fetch.apps/storefront/src/components/checkout/steps/ContactForm.tsx-11-11 (1)
11-11:⚠️ Potential issue | 🟠 MajorMake
onFormDataChangerequired to avoid silent non-editable checkout fields.Line 11 defines
onFormDataChangeas optional, and Lines 35/46/60/64/68/72/76/80 rely on optional chaining. If the prop is omitted, users can type but state never updates.💡 Proposed fix
interface ContactFormProps { formData: CheckoutFormData; - onFormDataChange?: (data: CheckoutFormData) => void; + onFormDataChange: (data: CheckoutFormData) => void; checkout: Dictionary["checkout"]; isGuest: boolean; hasSubscriptionItems: boolean; canConfirmContact: boolean; onContinue: () => void; }-<Input ... onChange={(e) => onFormDataChange?.({ ...formData, email: e.target.value })} /> +<Input ... onChange={(e) => onFormDataChange({ ...formData, email: e.target.value })} />Also applies to: 35-35, 46-46, 60-60, 64-64, 68-68, 72-72, 76-76, 80-80
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/steps/ContactForm.tsx` at line 11, The prop onFormDataChange is currently optional which permits callers to omit it and causes the form fields to appear editable but never update state; make onFormDataChange required in the ContactForm component's props (change the type from onFormDataChange?: (data: CheckoutFormData) => void to a non-optional signature) and remove optional chaining where it's invoked (calls in ContactForm where onFormDataChange?.(...) are used should become onFormDataChange(...)); ensure callers of ContactForm provide this callback so CheckoutFormData updates occur.apps/storefront/src/components/checkout/hooks/useShippingOptions.ts-68-70 (1)
68-70:⚠️ Potential issue | 🟠 MajorReset both options and selected id when cart/options are unavailable.
Line 69 returns early without clearing state, and Lines 117-119 clear only
shippingOptions. This can leave staleselectedShippingOptionIdfrom a previous cart.💡 Proposed fix
useEffect(() => { - if (!cartId) return; + if (!cartId) { + setShippingOptions([]); + setSelectedShippingOptionId(null); + return; + }.catch(() => { - if (!ac.signal.aborted) setShippingOptions([]); + if (!ac.signal.aborted) { + setShippingOptions([]); + setSelectedShippingOptionId(null); + } });Also applies to: 117-119
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/hooks/useShippingOptions.ts` around lines 68 - 70, When cartId or fetched options are unavailable the hook currently returns/clears only shippingOptions and leaves selectedShippingOptionId stale; update the useShippingOptions effect so that on the early-return for !cartId and in the branch that currently clears shippingOptions (the block around where shippingOptions is set to an empty array) you also reset selectedShippingOptionId (e.g., set it to null or an empty value). Locate the useShippingOptions hook and add a call to setSelectedShippingOptionId(...) alongside the existing setShippingOptions([]) in both the early-return path and the options-clearing branch so both state pieces are always cleared together.apps/storefront/src/components/account/PickupPointManager.tsx-154-157 (1)
154-157:⚠️ Potential issue | 🟠 MajorDo not rethrow after setting user-facing error feedback.
Line 156 rethrows a new error after handling the failure, which can cause unhandled promise rejections and noisy runtime errors for a recoverable UI action.
💡 Proposed fix
} catch { setPickupFeedback({ type: "error", msg: labels.pickupPointError }); - throw new Error("pickup save failed"); + return; } finally { setSavingPickup(false); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/account/PickupPointManager.tsx` around lines 154 - 157, In PickupPointManager, remove the rethrow in the catch block so the UI-handled error doesn't produce unhandled promise rejections: when catching the failure that calls setPickupFeedback({ type: "error", msg: labels.pickupPointError }), do not throw new Error("pickup save failed"); instead optionally capture the caught error (e.g., catch (err)) and log it via console.error or an existing logger, then allow the function to resolve/return so the UI flow remains stable.apps/storefront/src/components/checkout/hooks/useCheckoutPickup.ts-98-117 (1)
98-117:⚠️ Potential issue | 🟠 MajorHandle prefill fetch errors explicitly to avoid unhandled promise rejections.
The prefill chain has no
catch, so failures can bubble as unhandled rejections.💡 Proposed fix
void fetchAllPickupPoints({ zipcode: initialPickupZipcode, country_code: "DK" }) .then((points) => enrichWithDistance(points, initialPickupZipcode)) .then((points) => { if (!mountedRef.current) return; setPickupPoints(points); if (initialPickupPointId) { const match = points.find( (p) => p.number === initialPickupPointId || p.id === initialPickupPointId ); if (match) { setSelectedPoint(match); setSelectedCarrier( match.carrier_code === "dao" ? "dao" : match.carrier_code === "pdk" ? "pdk" : "gls" ); } } }) + .catch(() => { + if (!mountedRef.current) return; + setPickupPoints([]); + }) .finally(() => { if (mountedRef.current) setPickupLoading(false); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/hooks/useCheckoutPickup.ts` around lines 98 - 117, The promise chain starting with fetchAllPickupPoints(...) -> enrichWithDistance(...) lacks error handling and can produce unhandled rejections; update the chain around fetchAllPickupPoints, enrichWithDistance, mountedRef, setPickupPoints, setSelectedPoint, setSelectedCarrier, and setPickupLoading to include a .catch(...) that handles errors (e.g., log the error and/or set an error state) and ensure the existing .finally(...) still clears loading only after catch runs; keep mount checks (mountedRef.current) inside handlers to avoid state updates on unmounted components.apps/storefront/src/components/checkout/hooks/usePaymentSession.ts-180-186 (1)
180-186:⚠️ Potential issue | 🟠 MajorDon't auto-select
shipping_options[0]at payment time.If
selectedShippingOptionIdis missing or stale, this silently picks whateverlistCartOptions()returns first and writes it to the cart. That can create a payment session for the wrong carrier/service point instead of forcing an explicit delivery choice.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/hooks/usePaymentSession.ts` around lines 180 - 186, The code currently auto-selects shipping_options?.[0] when selectedShippingOptionId is missing which can silently overwrite the cart; change usePaymentSession so it does NOT default to shipping_options[0]: call medusa.store.cart.addShippingMethod only when selectedShippingOptionId is present and matches an id from listCartOptions (use shipping_options to validate), otherwise abort/throw or return an explicit error indicating the shipping option is required; reference variables/functions: selectedShippingOptionId, medusa.store.fulfillment.listCartOptions, shipping_options, medusa.store.cart.addShippingMethod, and selectedShippingData to implement the validation and avoid writing a default option to the cart.apps/storefront/src/components/checkout/hooks/usePaymentSession.ts-111-126 (1)
111-126:⚠️ Potential issue | 🟠 MajorInclude contact fields in the session reuse signature.
The reuse guard ignores
formData.emailandformData.phone, butmedusa.store.cart.update()writes both. If the shopper edits either field and returns to step 3, this short-circuits and leaves the cart/payment session tied to stale contact data.Possible fix
- const formDataSig = `${formData.firstName}|${formData.lastName}|${formData.address1}|${formData.postalCode}|${formData.city}`; + const formDataSig = JSON.stringify({ + email: formData.email.trim(), + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + address1: formData.address1.trim(), + postalCode: formData.postalCode.trim(), + city: formData.city.trim(), + phone: formData.phone?.trim() ?? "", + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/hooks/usePaymentSession.ts` around lines 111 - 126, The reuse signature omits contact fields so edits to formData.email or formData.phone can be ignored; update the signature creation (formDataSig) to include formData.email and formData.phone (e.g., append them into the template string used for formDataSig) and ensure any logic that writes or compares against lastAppliedFormDataRef.current continues to use that expanded signature so the guard in the if-block (which references formDataUnchanged) will detect contact changes and avoid reusing stale cart/payment sessions.apps/storefront/src/components/CheckoutWithStripe.tsx-105-120 (1)
105-120:⚠️ Potential issue | 🟠 MajorAsync prefill can clobber in-progress edits.
listAddress()resolves after the form is already interactive, thensetFormData(prefilled)replaces the whole state. If the shopper starts typing before that request finishes, their edits are lost.Possible fix
- setFormData(prefilled); + setFormData((current) => ({ + ...current, + email: current.email || prefilled.email, + firstName: current.firstName || prefilled.firstName, + lastName: current.lastName || prefilled.lastName, + address1: current.address1 || prefilled.address1, + postalCode: current.postalCode || prefilled.postalCode, + city: current.city || prefilled.city, + phone: current.phone || prefilled.phone, + })); }) .catch(() => { - setFormData(prefilled); + setFormData((current) => ({ + ...current, + email: current.email || prefilled.email, + firstName: current.firstName || prefilled.firstName, + lastName: current.lastName || prefilled.lastName, + phone: current.phone || prefilled.phone, + })); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/CheckoutWithStripe.tsx` around lines 105 - 120, The async listAddress() result currently calls setFormData(prefilled) which overwrites any user edits made while the request was pending; to fix, take a snapshot of the initial prefilled values before making medusa.store.customer.listAddress() and then update state with a functional update that only writes fields that are still unchanged (or empty) in the current form rather than replacing the whole object — e.g. in the .then handler call setFormData(prev => { const merged = {...prev}; for each key in prefilled (address1, postalCode, city, firstName, lastName) if prev[key] === '' || prev[key] === initialPrefilled[key] then merged[key]=prefilled[key]; return merged; }) so you reference medusa.store.customer.listAddress(), prefilled, setFormData and avoid clobbering in-progress edits.apps/storefront/src/components/sections/ContentBlockSection.tsx-15-15 (1)
15-15:⚠️ Potential issue | 🟠 Major
full-widthis declared as a valid layout but is never rendered.Line 15 accepts
"full-width", but Line 115 returnsnullfor it. This can silently drop CMS blocks.Proposed fix
- layout: "text-image" | "image-text" | "text-only" | "text-only-left" | "full-width"; + layout: "text-image" | "image-text" | "text-only" | "text-only-left";Also applies to: 115-115
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/sections/ContentBlockSection.tsx` at line 15, The union type for layout includes "full-width" but the ContentBlockSection render logic returns null for that case; either implement rendering for "full-width" in the ContentBlockSection component (handle the "full-width" branch where other layouts are rendered so CMS blocks aren’t dropped) or remove "full-width" from the layout union so it can’t be passed; update the switch/if that currently returns null (the render block around the return null for layout) to provide a concrete rendering strategy (e.g., full-bleed container, reuse text-only rendering with full-width styles, or a dedicated JSX branch) and ensure the prop type and any CMS mappings remain consistent.apps/storefront/src/components/sections/BlogCarouselSection.tsx-79-82 (1)
79-82:⚠️ Potential issue | 🟠 MajorAvoid rendering carousel links without a stable slug.
On Line 80 and Line 81, a missing slug can produce duplicate keys and route users to
/${locale}/blog/instead of an article page.Proposed fix
- {articles.map((a) => ( - <Link - key={a.slug ?? a.title ?? ""} - href={`/${locale}/blog/${a.slug ?? ""}`} + {articles.map((a) => { + if (!a.slug) return null; + return ( + <Link + key={a.slug} + href={`/${locale}/blog/${a.slug}`} className="group/card shrink-0 w-[72%] min-w-[220px] max-w-[260px] sm:max-w-[280px] md:w-[calc(25%-14px)] rounded-lg bg-white overflow-hidden transition-[box-shadow,color] focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none" > @@ - </Link> - ))} + </Link> + ); + })}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/sections/BlogCarouselSection.tsx` around lines 79 - 82, The Link elements in BlogCarouselSection use a.slug (with title fallback) for keys and to build hrefs, which leads to duplicate keys and wrong routes when a.slug is missing; update the rendering logic in the component (the Link mapping that references a.slug and a.title) to only render a Link when a.slug is present (or otherwise use a stable unique identifier like a.id for both key and href fallback), and ensure the key is a stable unique value (preferably a.slug or a.id) so you never render links that point to `/${locale}/blog/` or produce duplicate keys.apps/storefront/src/components/blog/ArticleCard.tsx-16-17 (1)
16-17:⚠️ Potential issue | 🟠 MajorGuard missing
article.slugbefore constructing the detail URL.Line 16 can create
/${locale}/blog/for slugless items, which is a broken detail navigation path for this card.Proposed fix
export function ArticleCard({ article, locale, dict, variant }: ArticleCardProps) { - const href = `/${locale}/blog/${article.slug ?? ""}`; + if (!article.slug) return null; + const href = `/${locale}/blog/${article.slug}`;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/blog/ArticleCard.tsx` around lines 16 - 17, The ArticleCard builds a broken detail URL when article.slug is missing; update the logic that computes href in ArticleCard.tsx so it first checks article.slug and only constructs `/${locale}/blog/${article.slug}` when present, otherwise set href to a safe fallback (e.g., empty string or undefined) and ensure the component (link/button around the card) respects that fallback to avoid rendering a broken link; keep the existing articleThumbnailUrl call unchanged.apps/storefront/src/components/analytics/PostHogProvider.tsx-17-30 (1)
17-30:⚠️ Potential issue | 🟠 MajorRe-consent path does not re-enable PostHog capture.
After
opt_out_capturing(), a later consent grant does not callopt_in_capturing()again because it is currently inside the first-init guard. The effect will skip the entire if block wheninitialized.currentis true, leaving analytics disabled for subsequent consent grants within the session.💡 Proposed fix
if (hasAnalyticsConsent) { if (!initialized.current) { posthog.init(POSTHOG_KEY, { api_host: POSTHOG_HOST, person_profiles: "identified_only", cookieless_mode: "on_reject", }); - posthog.opt_in_capturing(); initialized.current = true; } + posthog.opt_in_capturing(); } else { if (initialized.current) { posthog.opt_out_capturing(); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/analytics/PostHogProvider.tsx` around lines 17 - 30, The consent re-enable path currently skips opt_in_capturing because posthog.opt_in_capturing() is only called inside the first-init guard; update the logic in the PostHogProvider surrounding hasAnalyticsConsent to always call posthog.opt_in_capturing() when hasAnalyticsConsent becomes true (even if initialized.current is already true), while still only calling posthog.init(POSTHOG_KEY, ...) once (when initialized.current is false); ensure posthog.opt_out_capturing() remains called when hasAnalyticsConsent is false so subsequent opt-in correctly triggers opt_in_capturing().apps/storefront/src/components/cart/CartItemRow.tsx-50-53 (1)
50-53:⚠️ Potential issue | 🟠 MajorNormalize
subscription_cyclebefore deriving the subscription UI.Metadata values frequently round-trip as strings, and the current numeric-only branch collapses
"4"to0. That makes subscribed lines render unchecked and leaves the cycle selector with no matching option.[suggested fix]
Possible fix
- const cycle = typeof item.metadata?.subscription_cycle === "number" - ? item.metadata.subscription_cycle - : 0; - const isSubscription = cycle > 0; + const rawCycle = item.metadata?.subscription_cycle; + const parsedCycle = + typeof rawCycle === "number" + ? rawCycle + : typeof rawCycle === "string" + ? Number.parseInt(rawCycle, 10) + : 0; + const cycle = Number.isFinite(parsedCycle) ? parsedCycle : 0; + const isSubscription = cycle > 0;Also applies to: 123-139
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cart/CartItemRow.tsx` around lines 50 - 53, The subscription_cycle metadata is being treated as a number so string values like "4" become 0; update the logic in CartItemRow (the const cycle and isSubscription calculation that reads item.metadata?.subscription_cycle) to coerce string numbers into numeric form (e.g., use Number(...) or parseInt and check for NaN) and fall back to 0 on invalid values, then derive isSubscription as cycle > 0 so the subscription UI and cycle selector match string or numeric metadata.apps/storefront/src/components/cookie-consent/cookie-settings.tsx-29-29 (1)
29-29:⚠️ Potential issue | 🟠 MajorBulk accept/reject is derived from module defaults, not the rendered config.
The dialog renders
config.categories ?? defaultCategories, but both bulk handlers seedlocalCategoriesfrom helpers that never see that config. If a deployment overridesrequiredor default flags, this dialog can stage a consent map that disagrees with the categories the user just reviewed. Please derive those maps fromcategories(or pass the current config into the helpers).Also applies to: 52-64
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/cookie-settings.tsx` at line 29, The bulk accept/reject handlers seed localCategories from helper defaults instead of the actual rendered categories variable (const categories = config.categories ?? defaultCategories), causing mismatches when deployments override flags; update the handlers (bulk accept/reject) to derive their consent maps from the categories variable (or pass categories into the existing helper functions) so localCategories is built from the same source the UI renders (reference identifiers: categories, defaultCategories, localCategories, and the bulk accept/reject handler functions) and adjust any helper signatures used at lines ~52-64 to accept categories when constructing the maps.apps/storefront/src/components/cookie-consent/consent-script.tsx-85-88 (1)
85-88:⚠️ Potential issue | 🟠 MajorDouble execution of revoke handler confirmed.
The
onRevokecallback is registered in two separate paths and both execute during script unload:
- The config's
onRevoke(line 85-88) is stored in the script registry and called at line 142 ofscript-manager.ts- The same callback is separately registered via
registerCleanup()(line 110-114) and called again at lines 145-148 ofscript-manager.tsThis causes the user-provided
onRevokeprop to execute twice when consent is revoked. Remove theregisterCleanupregistration on line 110-114, as the config'sonRevokehandler already encompasses the necessary cleanup logic.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/consent-script.tsx` around lines 85 - 88, The supplied onRevoke callback is being executed twice because it's both placed into the script config (the object that is stored in the script registry via the onRevoke property) and also re-registered via registerCleanup(); remove the registerCleanup(...) registration that re-registers the same onRevoke cleanup (the block that calls setIsLoaded(false) and onRevoke?.()) so that only the config's onRevoke remains (which the script-manager will call from the registry); keep the onRevoke property in the config (the arrow function that calls setIsLoaded(false) and onRevoke?.()) and ensure no other duplicate cleanup registrations remain.apps/storefront/src/components/sections/HeroSection.tsx-97-99 (1)
97-99:⚠️ Potential issue | 🟠 MajorNormalize CTA URLs before prefixing the locale.
These three inline builders do not agree on URL handling. Already-localized paths can turn into
/${locale}/${locale}/..., and the full/simple variants also break absolute URLs and bare relative paths like"categories". Please centralize this in one resolver and use it for every hero variant.Also applies to: 151-153, 175-177
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/sections/HeroSection.tsx` around lines 97 - 99, Normalize CTA URLs by adding a single helper (e.g., resolveLocalizedUrl or normalizeCtaUrl) and use it wherever CTAs are built (the Link href builders in HeroSection component for all hero variants). The helper should: detect absolute URLs (leave unchanged), strip any leading/trailing slashes, avoid double-prefixing if the path already starts with `/${locale}` or contains the locale, and return either an absolute URL or a properly prefixed path `/${locale}/...` (or `/categories` fallback) so replace inline expressions like `/${locale}${cta.url ?? "/categories"}` in the Link hrefs (the instances at the three ranges noted) with calls to this resolver.apps/storefront/src/components/product/ProductPurchaseSection.tsx-110-116 (1)
110-116:⚠️ Potential issue | 🟠 MajorClamp the submitted quantity in the add-to-cart handler too.
The UI cap is enforced in an effect, which runs after render. If the user switches to a lower-stock variant and clicks immediately, this handler can still send the stale higher quantity to
addToCart().Clamp before submitting
const handleAddToCart = () => { if (!selectedVariantId || !stock.inStock) return; + const safeQuantity = Math.min(Math.max(1, quantity), maxSelectable); + if (safeQuantity !== quantity) setQuantity(safeQuantity); setError(null); startTransition(async () => { try { const options = purchaseType === "subscription" && subscriptionConfig ? { subscription_cycle: selectedCycle } : undefined; - const addCart = (await addToCart(selectedVariantId, quantity, options)) as + const addCart = (await addToCart( + selectedVariantId, + safeQuantity, + options + )) as | StoreCart | undefined; @@ productTitle: last.product_title ?? last.title ?? "", variantTitle: last.variant_title ?? last.variant?.title, thumbnail: last.thumbnail ?? last.variant?.product?.thumbnail, - quantity: last.quantity ?? quantity, + quantity: last.quantity ?? safeQuantity, unitPrice: getLineUnitPrice(last) || selectedVariantPrice,Also applies to: 139-165
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/product/ProductPurchaseSection.tsx` around lines 110 - 116, The add-to-cart submission can send a stale quantity because UI-only clamping happens in useEffect; modify the add-to-cart handler (e.g. handleAddToCart / the function that calls addToCart in ProductPurchaseSection.tsx) to compute a local clampedQuantity using stock.inStock and stock.maxQuantity (cap at 99, min 1) and pass that clampedQuantity to addToCart instead of the raw quantity state; apply the same clamping logic to the other purchase handler referenced in the comment (the buy/checkout handler around lines 139-165) so both handlers never submit a stale/unclamped quantity.apps/storefront/src/components/sections/PromotionSlider.tsx-108-130 (1)
108-130:⚠️ Potential issue | 🟠 MajorLinked slides need an accessible name.
When
hrefis present, the link contains only an image withalt="", so assistive tech gets an unlabeled interactive element. Please add per-slide alt/label text and wire it to the link or image.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/sections/PromotionSlider.tsx` around lines 108 - 130, The linked slide currently renders an image with alt="" making the interactive Link unlabeled; update PromotionSlider so each slide uses a meaningful accessible name (e.g., slide.altText, slide.title, or slide.description) and apply it to the image alt attribute and/or the Link aria-label when href is present (refer to slide.id, href, inner and the img element). If a slide lacks explicit alt text, fall back to a short descriptive string derived from slide.title or slide.description, and ensure the Link has the same label (aria-label) so screen readers announce the interactive element.apps/storefront/src/components/cookie-consent/use-consent-script.ts-93-98 (1)
93-98:⚠️ Potential issue | 🟠 MajorClear stale load errors before exposing the hook state again.
Once
loadScript()rejects,erroris never reset. That leaves consumers stuck with a stale error even after a later successfulload(), and the auto-load path stays blocked until something else clears it.Also applies to: 123-161
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/use-consent-script.ts` around lines 93 - 98, The hook never clears a previous error, so consumers remain stuck after a failed load; update the success and revoke paths to reset the error state: call setError(null) in the onLoad handler before/when calling setIsLoaded(true) and also clear the error in the onRevoke path (alongside setIsLoaded(false) and onRevokeRef.current()), and ensure the same error-clear logic is applied in the other load path(s) (the loadScript()/load() auto-load code around the 123-161 region) so a later successful load removes any stale error.apps/storefront/src/components/sections/PromotionSlider.tsx-30-33 (1)
30-33:⚠️ Potential issue | 🟠 MajorDon't double-prefix localized slide URLs.
resolveHref()prepends/${locale}for every non-HTTP URL, so CMS values like/da/fooorda/foobecome/da/da/foo. Normalize an existing locale prefix before adding the current locale.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/sections/PromotionSlider.tsx` around lines 30 - 33, resolveHref currently always prepends `/${locale}` to non-http URLs which causes double prefixes when the CMS value already contains the locale (e.g. `/da/foo` -> `/da/da/foo`); update resolveHref to first normalize the incoming slideHref by stripping an existing leading `/{locale}` or `{locale}` if present (handle both with and without the leading slash), then build the final href by prepending `/${locale}` and ensuring a single slash between locale and path (still return undefined for falsy slideHref and return absolute http(s) URLs unchanged). Use the resolveHref function name to locate where to implement this normalization.apps/storefront/src/components/ProductCard.tsx-95-97 (1)
95-97:⚠️ Potential issue | 🟠 MajorDon't silently drop quick-add failures.
This control prevents navigation and then ignores every add-to-cart error. If the request fails, the button just appears dead with no feedback or fallback path.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/ProductCard.tsx` around lines 95 - 97, The catch block in ProductCard.tsx currently swallows quick-add failures; update the catch to accept the error (e.g., catch (err)) and handle it: log the error (console.error or processLogger), reset any loading state (call setIsAdding(false) or equivalent), show user feedback (call toast.error or set an error state to render a message), and provide a fallback (e.g., navigate to the product page via navigate/to router.push or call the existing handleFullAdd flow) so the button doesn't appear dead. Make these changes inside the ProductCard component's quick-add handler to ensure failures are visible and the UI is restored.apps/storefront/src/components/cookie-consent/use-consent-script.ts-82-120 (1)
82-120:⚠️ Potential issue | 🟠 MajorAdd
optionsto the effect dependency array or memoize it.This effect only depends on
id, but captures and registerssrc,content,category,strategy, andattributesfromoptions. If any of these change whileidremains the same, the old registration persists and the hook loads the stale script definition instead of the updated one. TheonRevokecallback is kept in sync viauseLayoutEffect, but the script definition itself is not. Either add the relevant option properties to the dependency array or memoize theoptionsobject to ensure re-registration when the script definition changes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/use-consent-script.ts` around lines 82 - 120, The effect in use-consent-script.ts registers script data from the options object but only depends on id, causing stale registrations when options (src, content, category, strategy, attributes) change; update the dependency list of the React.useEffect that contains isRegisteredRef, registerScript, ctxRegister, unregisterScript and onRevokeRef to include either a memoized options (e.g., memoize options before the effect) or the specific option properties (options.src, options.content, category, options.strategy, options.attributes) so the effect re-runs and re-registers the script whenever the script definition changes.apps/storefront/src/components/cookie-consent/script-manager.ts-5-8 (1)
5-8:⚠️ Potential issue | 🟠 MajorMake
loadScript()idempotent while a load is in flight.
managed.loadedstaysfalseuntilonload/the timeout runs, so a secondloadScript(id)before that point appends another<script>tag. That can re-execute inline code or initialize the same SDK twice. Keep a shared in-flight promise, or mark the element before appending, so parallel callers reuse one load.Also applies to: 47-119
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/script-manager.ts` around lines 5 - 8, The loadScript(id) function is not idempotent while a load is in-flight because ManagedScript.loaded stays false until onload/timeout, so concurrent callers append duplicate <script> tags; fix by adding an in-flight marker (e.g., a loadPromise property on ManagedScript) or marking the element before appending and reusing it: in loadScript(id) first check managed.loadPromise or managed.element and if present return the existing Promise; if not, create and assign managed.loadPromise (and set managed.element prior to appending) and resolve/reject it in the onload/onerror/timeout handlers while also setting managed.loaded = true and clearing managed.loadPromise on completion so parallel callers share the same load operation (refer to ManagedScript, loadScript, element, loaded in the diff).apps/storefront/src/components/cookie-consent/cookie-provider.tsx-91-114 (1)
91-114:⚠️ Potential issue | 🟠 MajorHonor explicit
googleConsentModeconfig when rendering.
effectiveGoogleConsentModecan already be enabled by config at Lines 95-96, but the render path still requireshasGoogleScripts. That makes the explicit-config path a no-op for GTM/gtag integrations loaded outside this registry.Suggested fix
- {hasGoogleScripts && effectiveGoogleConsentMode?.enabled && ( + {effectiveGoogleConsentMode?.enabled && ( <GoogleConsentMode defaults={{ analytics_storage: "denied", ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied", }} regions={effectiveGoogleConsentMode?.regions} /> )}Also applies to: 363-372
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/cookie-provider.tsx` around lines 91 - 114, The computed effectiveGoogleConsentMode currently prefers config.googleConsentMode but later render logic still gates GTM/gtag output on hasGoogleScripts, nullifying explicit config; update render checks to rely on effectiveGoogleConsentMode (e.g., effectiveGoogleConsentMode?.enabled) instead of hasGoogleScripts so explicit config works even when scripts are loaded externally, and apply the same change to the related render block referenced around the second occurrence (the block currently using hasGoogleScripts at the other location).apps/storefront/src/components/cookie-consent/cookie-provider.tsx-157-163 (1)
157-163:⚠️ Potential issue | 🟠 Major
expirationDaysnever expires the saved consent.
expiresAtis computed here, but it only goes totrackConsent; the browser record saved at Line 203 has no expiry metadata, and the init path accepts any version-matching consent forever. Users will not be re-prompted after the configured lifetime.Also applies to: 188-203
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/cookie-provider.tsx` around lines 157 - 163, The saved consent currently omits expiry metadata so stored consent never expires; update the consent persistence (the code path used by trackConsent) to include an expiresAt timestamp computed from expiresAt/expirationDays, and when initializing (the branch that checks stored && stored.consentVersion and calls setState, previousCategoriesRef.current and loadConsentedScripts) also verify stored.expiresAt is still in the future (e.g., Date.now() < stored.expiresAt) before accepting it; if expired, treat as no stored consent so the banner shows. Also update any consent type/interface (and the save/load logic) to include expiresAt so checks are type-safe.apps/storefront/src/components/cookie-consent/script-manager.ts-36-42 (1)
36-42:⚠️ Potential issue | 🟠 MajorDon't unregister cleanup after the first revoke.
unloadScript()deletes the cleanup callback right after calling it. If the user later opts back in and then revokes again, the second unload skips cookie/global teardown entirely. Keep the cleanup registered across load/unload cycles and delete it only when the script is permanently removed from the registry.Suggested fix
export function unregisterScript(id: string): void { const script = scriptRegistry.get(id); if (script) { unloadScript(id); scriptRegistry.delete(id); + cleanupRegistry.delete(id); } } @@ - const cleanup = cleanupRegistry.get(id); - if (cleanup) { - cleanup(); - cleanupRegistry.delete(id); - } + cleanupRegistry.get(id)?.();Also applies to: 145-148, 253-258
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/script-manager.ts` around lines 36 - 42, unregisterScript currently calls unloadScript(id) which clears the script's cleanup callback, then immediately deletes the registry entry—this loses the cleanup for future unload/load cycles; change unregisterScript to call unloadScript(id) but do not call scriptRegistry.delete(id) so the script's cleanup remains registered across re-loads, and add/use a separate removal path (e.g., removeScriptPermanently or the existing permanent-delete location) that calls scriptRegistry.delete(id) only when the script is truly removed; also search for other occurrences where unloadScript(...) is immediately followed by scriptRegistry.delete(...) and apply the same pattern so cleanup callbacks persist across opt-in/opt-out cycles.apps/storefront/src/components/cookie-consent/utils.ts-20-30 (1)
20-30:⚠️ Potential issue | 🟠 MajorGuard all storage operations with try/catch blocks.
The
typeof windowcheck does not protect againstSecurityErrorthrown bylocalStorage.getItem(),setItem(), orremoveItem()in blocked-storage environments. Line 25 (getVisitorId), line 71 (loadConsentState), line 62 (saveConsentState), and line 86 (clearConsentState) all perform unguarded storage access. This breaks consent initialization and visitor tracking in restricted contexts. Wrap every storage call intry/catchand fall back to generated UUIDs or null values.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/utils.ts` around lines 20 - 30, Wrap every direct localStorage call in getVisitorId, loadConsentState, saveConsentState, and clearConsentState with try/catch to guard against SecurityError in blocked-storage environments: for getVisitorId, catch errors around getItem/setItem and fall back to generateUUID (but do not throw); for loadConsentState return null on any storage error; for saveConsentState swallow storage errors after attempting setItem; and for clearConsentState swallow errors after attempting removeItem; keep existing VISITOR_ID_KEY and function names unchanged and ensure callers receive the UUID or null fallback values instead of propagating exceptions.apps/storefront/src/components/cookie-consent/script-manager.ts-266-277 (1)
266-277:⚠️ Potential issue | 🟠 MajorFix cookie deletion to handle parent-domain cookies.
These helpers cannot delete cookies set with parent-domain attributes (e.g.,
Domain=.example.com). The code attemptsdomain=www.example.comanddomain=.www.example.com, which don't match cookies originally set on parent domains. GA and FB cookies typically use parent-domain attributes and will survive revoke/reset with the current logic.Delete without domain first (removes host-only cookies), then iterate parent domains. For
www.example.com, try deleting withDomain=.example.com; forapi.example.com, tryDomain=.example.com, etc.This applies to all cleanup helpers:
googleAnalytics(lines 266–277),facebookPixel(lines 289–300), andclearCookiesByPrefix(lines 302–310).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/cookie-consent/script-manager.ts` around lines 266 - 277, The cookie removal logic in googleAnalytics, facebookPixel and clearCookiesByPrefix fails for cookies set on parent domains; update each helper to first delete the cookie without a Domain attribute (to remove host-only cookies) and then compute the parent domain chain from window.location.hostname (e.g., for www.example.com try .example.com) and iterate that chain setting document.cookie with Domain=<parentDomain> for each parent (prefixed with a dot) and path=/ to ensure cookies set on parent domains are removed; apply the same approach to the helpers named googleAnalytics, facebookPixel and clearCookiesByPrefix so they try host-only deletion first then each parent-domain deletion.
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.41.1)
apps/storefront/src/components/blog/ArticleFeed.tsx
[warning] 30-30: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
apps/storefront/src/components/sections/ContentBlockSection.tsx
[warning] 48-48: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
apps/storefront/src/components/cookie-consent/utils.ts
[warning] 133-136: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(
(^|//|\\.)${domainLower.replace(/\./g, "\\.")}(/|:|$|\\?),
"i"
)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html
(regexp-from-variable)
🪛 Stylelint (17.5.0)
apps/storefront/src/app/globals.css
[error] 1-450: Unknown rule scss/at-rule-no-unknown. Did you mean at-rule-no-unknown?
(scss/at-rule-no-unknown)
- Stylelint: use at-rule-no-unknown for Tailwind v4 - Next: Medusa host in images.remotePatterns for checkout thumbnails - A11y: HeaderBackButton, ProfileForm, AccountLayout mobile sheet dialog - Checkout: required ContactForm callback; noop default in CheckoutSteps - useShippingOptions: reset selection when cart cleared or fetch fails - usePaymentSession: fuller form fingerprint; explicit shipping when multiple options - Pickup: no rethrow in PickupPointManager; prefill catch; sheet search seq + errors - Content: full-width ContentBlock; blog slug guards; PromotionSlider href + labels - PostHog: opt_in_capturing on re-consent after init - Cart/ProductCard: subscription_cycle parsing; toast on quick-add failure - i18n: checkout.shippingMethodRequired (da/en) Made-with: Cursor
There was a problem hiding this comment.
Actionable comments posted: 16
🧹 Nitpick comments (2)
apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx (1)
34-39: Prefer a concretecartguard over repeatedas StoreCartassertions.A single null guard keeps helper calls type-safe and removes assertion noise.
♻️ Suggested cleanup
-import type { StoreCart } from "@/lib/cart-data"; @@ - const items = (cart?.items ?? []) as CartItem[]; - if (items.length === 0) return null; - - const itemsOriginalTotal = getCartItemsOriginalTotal(cart as StoreCart); - const discountTotal = getCartDiscountTotal(cart as StoreCart); - const itemsTotal = getCartItemsTotal(cart as StoreCart); + if (!cart?.items?.length) return null; + const items = cart.items as CartItem[]; + + const itemsOriginalTotal = getCartItemsOriginalTotal(cart); + const discountTotal = getCartDiscountTotal(cart); + const itemsTotal = getCartItemsTotal(cart);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx` around lines 34 - 39, Add a concrete null/undefined guard for `cart` at the top of `CheckoutOrderSummary` (e.g., if (!cart) return null) so the compiler narrows the type; then remove the repeated `as StoreCart` assertions and call `getCartItemsOriginalTotal`, `getCartDiscountTotal`, and `getCartItemsTotal` directly with the now-narrowed `cart`; also update how `items` is derived to rely on the gated `cart` (e.g., use `cart.items ?? []` or keep the existing `items` check) so all helper calls are type-safe without casts.apps/storefront/src/components/sections/PromotionSlider.tsx (1)
112-119: Defer fetching banners that are not visible yet.Each slide renders an
<img>without aloadinghint, so the browser can start downloading every banner on first paint. On the homepage that turns one hero into several large initial requests. Make non-visible slides lazy so only the current/nearby slide is prioritized.One possible direction
<img src={desktopUrl} alt="" width={2560} height={875} className="h-full w-full object-cover" + loading={ + index === current || index === (current + 1) % slides.length + ? "eager" + : "lazy" + } fetchPriority={index === 0 ? "high" : undefined} />🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/storefront/src/components/sections/PromotionSlider.tsx` around lines 112 - 119, The img tags in PromotionSlider.tsx always start fetching because they lack a loading hint; update the <img> element (the one using props fetchPriority and index) to set loading="lazy" for non-visible slides and only use eager/high priority for the currently visible (or immediately adjacent) slide — i.e., conditionally set loading based on the component's current slide index (e.g., activeIndex/currentIndex) and keep fetchPriority only for the primary slide (index === currentIndex). This ensures non-visible banners are deferred while the visible/nearby hero is prioritized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/storefront/src/components/AccountLayout.tsx`:
- Around line 49-55: The current-section label and active-link logic are
inconsistent for trailing-slash overview routes; update getCurrentSectionLabel
(and the corresponding active-link check logic used elsewhere around the account
nav) to normalize the pathname before matching—e.g., strip a trailing "/" or use
a helper normalizePath(pathname) so comparisons treat "/{locale}/account" and
"/{locale}/account/" the same; adjust getCurrentSectionLabel (and the
active-link aria-current check) to use that normalized pathname or use
startsWith for the overview route to keep label and active state in sync.
- Around line 120-129: The mobile sheet stays open when the viewport grows to md
because menuOpen remains true; update the effect around menuOpen in
AccountLayout (the useEffect shown) to also watch a media query or window resize
and set menuOpen to false when the viewport crosses into desktop (>= md).
Specifically, inside the useEffect (or a new effect) add a matchMedia listener
for the md breakpoint (e.g., window.matchMedia('(min-width: 768px)')) that, on
match, calls the state setter to close the sheet (setMenuOpen(false) or call
closeMenu) and ensure you remove the listener in the cleanup; keep the existing
body overflow handling and cleanup intact.
- Around line 150-156: The AccountLayout sheet currently sets role="dialog" and
aria-modal="true" but does not manage focus; update the component to move focus
into the sheet when opened, trap Tab/Shift+Tab inside the sheet while menuOpen
is true, and restore focus to the trigger button when closed: store refs for the
trigger button (where setMenuOpen(true) is called) and the sheet container, on
menuOpen true programmatically focus the first focusable element in the sheet
(or the sheet container) and attach a keydown handler to trap Tab/Shift+Tab, and
on close remove the handler and call focus() on the stored trigger ref;
alternatively replace the DIY logic with a tested focus-trap/dialog helper
(e.g., Reach Dialog, react-aria useDialog/useFocusScope, or focus-trap) applied
to the sheet component so that opening via setMenuOpen and the menuOpen state
handles enter/trap/restore correctly.
In `@apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx`:
- Around line 191-193: The component CheckoutOrderSummary contains hardcoded
locale ternaries (e.g., the "Beregnes ved valg af levering" / "Calculated when
delivery method is selected" string and the other occurrence around the delivery
copy) which bypass the app i18n flow; add new translation keys in the i18n
dictionaries (for example checkout.calculatedWhenDeliverySelected and
checkout.<other_key>) and replace the inline locale checks in
CheckoutOrderSummary with the i18n lookup used elsewhere (e.g.,
t('checkout.calculatedWhenDeliverySelected')) so both occurrences use the
central dictionary instead of hardcoded strings.
In `@apps/storefront/src/components/checkout/hooks/useCheckoutPickup.ts`:
- Around line 33-42: selectedCarrier can become stale when selectedPoint is
cleared because some code paths call setSelectedPoint without updating
selectedCarrier; update the useCheckoutPickup hook so every place that clears or
resets the pickup point also updates selectedCarrier (e.g., modify the
search-related callback that currently calls setSelectedPoint, the selectPoint
function, and the prefill/match flow) to call setSelectedCarrier with the
intended default or derived carrier (or null) atomically with setSelectedPoint;
ensure any helper that exposes setSelectedPoint externally is replaced with a
wrapper that sets both selectedPoint and selectedCarrier together so carrier
state remains synchronized with point resets.
In `@apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts`:
- Around line 61-79: The current fetch flow in usePickupPointSheetSearch clears
the confirmed pickup point unconditionally (setSelectedPoint(null)) when any
successful fetch completes; change it so that after enrichWithDistance resolves
you check mountedRef.current and seq === fetchRequestSeqRef.current and then
only clear selectedPoint if the previously selected point is not present in the
returned points list (compare by the point's unique id/property), otherwise keep
the existing selectedPoint; additionally ensure the request is invalidated when
the sheet closes by incrementing fetchRequestSeqRef.current (or setting a closed
flag) when the sheet unmounts/close handler runs so any late promises are
ignored—apply the same fix to both fetch-result blocks (the block shown and the
one at 99-120) and update logic around setPickupLoading to respect the same
seq/closed checks.
- Around line 58-60: The current zipForSearch falls back to combined.slice(0, 4)
and can turn street text into a bogus ZIP; change the fallback so zipForSearch
uses zip if >=3, otherwise use extractZipcodeFromAddress(combined) and if that
returns empty use an empty string (do not use combined.slice(0,4)), then only
call fetchAllPickupPoints when zipForSearch.length >= 3; update the code around
zipForSearch in usePickupPointSheetSearch.ts (referencing the zipForSearch
variable and the extractZipcodeFromAddress and fetchAllPickupPoints calls)
accordingly.
In `@apps/storefront/src/components/ProductCard.tsx`:
- Around line 204-209: The Link in ProductCard.tsx that navigates to productHref
currently uses aria-label={a11y.addToCart} which mislabels the fallback CTA;
update the aria-label on that Link (the element with href={productHref} and
onClick={(e) => e.stopPropagation()}) to a navigation-appropriate label such as
"View product" or use a dedicated a11y key like a11y.viewProduct so assistive
tech announces the correct action.
- Line 89: The unitPrice assignment in ProductCard (unitPrice:
getLineUnitPrice(last) || product.price) erroneously treats 0 as falsy and falls
back to product.price; change the fallback to preserve legitimate zero values by
using a nullish check (e.g., replace the || with ?? or explicitly check for
undefined/null) so getLineUnitPrice(last) is used when it returns 0 and
product.price is only used when getLineUnitPrice returns null/undefined.
- Around line 77-101: The catch is treating any error the same, so failures in
refreshCart() / fetchClientStoreCart() hide successful addToCart() results and
prompt a "Could not add to cart" message; fix by splitting the operation into
two try/catch blocks: first call addToCart(vid, 1) inside its own try and handle
its error with the "Could not add to cart" toast and return, then in a second
try use refreshCart(), fetchClientStoreCart(), resolveAddedLineItem(...) and
openModal(...) and catch only sync/display errors (log them and show a different
toast like "Cart sync failed" or similar) so a successful add is not mistaken
for failure; reference addToCart, refreshCart, fetchClientStoreCart,
resolveAddedLineItem, openModal, and the toast.error calls when making the
change.
- Around line 109-141: The wishlist <button> is currently nested inside the Link
(anchor) which creates invalid interactive nesting; move the button out of the
Link in the ProductCard component so the Link only wraps the image/title, keep
the same visual placement by making the outer wrapper (the element that
currently contains Link and button) position:relative and apply the button’s
absolute positioning there, preserve the existing onClick handler calling
toggleWishlist(product.id) (including e.preventDefault()/e.stopPropagation() so
clicks don’t trigger navigation), and keep the aria-label logic
(a11y.removeFromWishlist / a11y.addToWishlist) and the Heart icon classes
intact; update any className/structure around ImageWithFallback, productHref and
the wrapper so layout and focus behavior remain the same.
In `@apps/storefront/src/components/sections/ContentBlockSection.tsx`:
- Around line 32-37: The ctaHref computation can produce malformed paths and
mis-handle non-http schemes; update the logic in ContentBlockSection where
ctaHref is derived (referencing showCta, cta?.url, cta.url.startsWith and
locale) to: 1) early-return undefined if no showCta or no cta?.url; 2) treat
fully-qualified URLs and non-http schemes (mailto:, tel:, sms:, ftp:, data:,
etc.) as absolute and return them unchanged; 3) detect already-localized paths
that begin with `/${locale}/` or `/${locale}` and return them unchanged; and 4)
for other relative paths, prefix with `/${locale}` ensuring exactly one slash
(avoid `//`) by trimming leading/trailing slashes on cta.url before
concatenation. Ensure these checks are applied in order to avoid
double-localization and preserve absolute schemes.
- Around line 110-119: The two-column grid is always applied for layouts
"text-image" / "image-text" even when imageBlock is null, producing an empty
column; update the rendering in ContentBlockSection so the container uses a
single-column fallback when imageBlock is falsy — e.g., compute a cols class
based on whether imageBlock exists (use layout and isImageFirst to decide
placement, but only add "lg:grid-cols-2" when imageBlock is present, otherwise
use "lg:grid-cols-1") and apply that class in place of the hard-coded
"lg:grid-cols-2" in the div with className "grid ... lg:grid-cols-2 gap-6 ...";
keep existing variables layout, isImageFirst, imageBlock and textBlock and only
change how the grid class is composed.
- Around line 47-50: The ContentBlockSection component exposes contentHtml as a
plain string and directly uses dangerouslySetInnerHTML; change the component API
to enforce an HTML trust boundary by either sanitizing at render time or
requiring a branded trusted type: Option A (recommended) — add
isomorphic-dompurify, import DOMPurify into ContentBlockSection, run
DOMPurify.sanitize(contentHtml, {ALLOWED_TAGS/OPTIONS as needed}) and pass the
sanitized result to dangerouslySetInnerHTML; Option B — define and export a
branded type (e.g., type TrustedHtml = string & { __brand: "TrustedHtml" }) and
update ContentBlockSection’s prop (contentHtml: TrustedHtml) so callers must
produce trusted HTML via a sanitizer helper (e.g., lexicalToHtmlSanitize) before
constructing TrustedHtml; update all call sites (lexicalToHtml usage) to either
sanitize or cast to TrustedHtml only after sanitization.
In `@apps/storefront/src/components/sections/PromotionSlider.tsx`:
- Around line 9-15: Add a per-slide accessible label to the slide model and use
it for the linked tile’s accessible name: extend the PromotionSliderSlideData
interface to include a required string like "altText" or "ariaLabel" and update
any code creating slides to supply that text; then update the PromotionSlider
component (the slide rendering / link element) to use that property for the
link's accessible name (e.g., as aria-label or as alt text on the img and ensure
the full-tile link uses the same label) instead of the generic "Go to slide N"
label (affecting the code around PromotionSliderSlideData and the link/image
rendering blocks referenced in the file).
- Around line 58-62: The autoplay effect in PromotionSlider (useEffect using
slides.length, paused, goNext, AUTOPLAY_MS) only pauses on mouse hover — update
it to also pause on focus and other user interactions and expose a visible
play/pause control when the multi prop is true: add event handlers to set the
paused state on focusin/focusout and touchstart/touchend (or
pointerdown/pointerup) and ensure the interval is cleared when paused or when
any interaction/focus occurs; implement a PlayPause button component rendered
when multi===true that toggles the paused state and is keyboard-focusable, and
update any other autoplay useEffect blocks (the similar block around
goPrev/goNext) to use the same paused/interaction logic.
---
Nitpick comments:
In `@apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx`:
- Around line 34-39: Add a concrete null/undefined guard for `cart` at the top
of `CheckoutOrderSummary` (e.g., if (!cart) return null) so the compiler narrows
the type; then remove the repeated `as StoreCart` assertions and call
`getCartItemsOriginalTotal`, `getCartDiscountTotal`, and `getCartItemsTotal`
directly with the now-narrowed `cart`; also update how `items` is derived to
rely on the gated `cart` (e.g., use `cart.items ?? []` or keep the existing
`items` check) so all helper calls are type-safe without casts.
In `@apps/storefront/src/components/sections/PromotionSlider.tsx`:
- Around line 112-119: The img tags in PromotionSlider.tsx always start fetching
because they lack a loading hint; update the <img> element (the one using props
fetchPriority and index) to set loading="lazy" for non-visible slides and only
use eager/high priority for the currently visible (or immediately adjacent)
slide — i.e., conditionally set loading based on the component's current slide
index (e.g., activeIndex/currentIndex) and keep fetchPriority only for the
primary slide (index === currentIndex). This ensures non-visible banners are
deferred while the visible/nearby hero is prioritized.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 2cea00f6-fa2a-414c-be82-e116e6b91e62
📒 Files selected for processing (25)
.stylelintrc.jsonapps/storefront/next.config.tsapps/storefront/src/components/AccountLayout.tsxapps/storefront/src/components/CheckoutSteps.tsxapps/storefront/src/components/CheckoutWithStripe.tsxapps/storefront/src/components/ProductCard.tsxapps/storefront/src/components/account/PickupPointManager.tsxapps/storefront/src/components/account/ProfileForm.tsxapps/storefront/src/components/analytics/PostHogProvider.tsxapps/storefront/src/components/blog/ArticleCard.tsxapps/storefront/src/components/cart/CartItemRow.tsxapps/storefront/src/components/checkout/CheckoutOrderSummary.tsxapps/storefront/src/components/checkout/HeaderBackButton.tsxapps/storefront/src/components/checkout/hooks/useCheckoutPickup.tsapps/storefront/src/components/checkout/hooks/usePaymentSession.tsapps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.tsapps/storefront/src/components/checkout/hooks/useShippingOptions.tsapps/storefront/src/components/checkout/steps/ContactForm.tsxapps/storefront/src/components/sections/BlogCarouselSection.tsxapps/storefront/src/components/sections/ContentBlockSection.tsxapps/storefront/src/components/sections/PromotionSlider.tsxapps/storefront/src/i18n/dictionaries.tsapps/storefront/src/i18n/dictionaries/da.jsonapps/storefront/src/i18n/dictionaries/en.jsonopenmemory.md
✅ Files skipped from review due to trivial changes (2)
- .stylelintrc.json
- apps/storefront/src/components/checkout/steps/ContactForm.tsx
🚧 Files skipped from review as they are similar to previous changes (9)
- apps/storefront/next.config.ts
- apps/storefront/src/components/checkout/HeaderBackButton.tsx
- apps/storefront/src/components/cart/CartItemRow.tsx
- apps/storefront/src/components/sections/BlogCarouselSection.tsx
- apps/storefront/src/components/account/ProfileForm.tsx
- apps/storefront/src/components/blog/ArticleCard.tsx
- apps/storefront/src/components/analytics/PostHogProvider.tsx
- apps/storefront/src/components/CheckoutWithStripe.tsx
- apps/storefront/src/components/checkout/hooks/usePaymentSession.ts
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.41.1)
apps/storefront/src/components/sections/ContentBlockSection.tsx
[warning] 48-48: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🔇 Additional comments (5)
apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx (1)
47-61: Shipping precedence and free-shipping handling is well implemented.This block correctly prioritizes explicit zero shipping and avoids stale
cart.shipping_totaloverriding free-shipping state.apps/storefront/src/components/AccountLayout.tsx (1)
41-47: Nice extraction of the account route map.Both nav variants now read from the same href/label source of truth, which should make future account-route changes much harder to drift.
apps/storefront/src/components/checkout/hooks/useShippingOptions.ts (1)
103-126: Nice fallback guard here.Checking
ac.signal.abortedbefore either state update avoids stale responses from the failed primary path overwriting newer cart data.apps/storefront/src/components/account/PickupPointManager.tsx (1)
99-119: Nice reopen preselect flow.Resetting the guard on close and then matching by both pickup-point id and
carrier_codeis a good way to restore the saved point without reselecting the wrong carrier after a refetch.apps/storefront/src/components/CheckoutSteps.tsx (1)
76-107: Nice extraction.Moving pickup-point state behind
useCheckoutPickupkeeps this component focused on step transitions, and theonRegisterGoToStepbridge stays easy to follow.
- AccountLayout: normalizePath for nav/title; close sheet on md+; focus trap + restore - CheckoutOrderSummary: dict keys for VAT/shipping helper copy; narrow cart type - Pickup: sync carrier when point cleared; sheet close invalidates fetch seq; keep point if still in results; safer zip fallback - ContentBlockSection: CTA href scheme/locale; single column without image - PromotionSlider: accessibleLabel; play/pause; focus/touch pause; lazy offscreen imgs - ProductCard: wishlist outside image link; split add vs sync errors; viewProduct a11y; unit price ?? - i18n: checkout + promoSlider pause/play strings Made-with: Cursor
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Made-with: Cursor
Made-with: Cursor
Made-with: Cursor
* t1.6: staging branch strategy (main=production) (#2)
* t1.6: add staging branch strategy (main=production)
Create staging branch workflow docs: PRs target staging; releases promote staging to main.
* [t1.6] chore: add .sdd/git-config.json for branch strategy
Commands now automatically use staging as the default PR target.
This config tells all SDD helpers and skills that:
- default_branch = staging (PR target)
- production_branch = main (releases)
* feat: SDD guardrails + dynamic batch scheduler (#3)
* feat: add SDD guardrails + dynamic batch scheduler
- Add GitHub Actions: CI (SDD sanity) + PR policy (block feature→main)
- Add PR template for consistent PR descriptions
- Add branch protection setup guide (.github/BRANCH-PROTECTION.md)
- Implement dynamic batch scheduler with worktree support (max 2 parallel)
- Scheduler script: .cursor/scripts/sdd-scheduler.cjs
- Worktree helper: .cursor/commands/_shared/worktree-scheduler.md
- Add /task/promote command for staging→main releases
- Update SDD commands/skills to use staging as default PR target
- Updated: task/start.md, task/validate.md, task/batch.md
- Updated: sdd-pr-create-or-update/SKILL.md
- Update batch-runner agent for worktree parallel execution
- Add milestone-ready checklist (generic, not milestone-specific)
- Update documentation (spec/08-infrastructure.md, README.md, openmemory.md)
- Update .gitignore to exclude .sdd/worktrees/
* config: enable CodeRabbit auto-review on staging branch
- Add .coderabbit.yaml configuration
- Enable auto-review on both main (default) and staging branches
- CodeRabbit will now automatically review PRs targeting staging
* docs: update branch protection guide with GitHub Rulesets UI
* fix: move review settings under reviews section in coderabbit.yaml
- Move review_status, review_details, high_level_summary under reviews:
- Fixes CodeRabbit validation warning about unrecognized properties
* docs: add JSDoc docstrings to sdd-scheduler functions
- Add docstrings to all exported and internal functions
- Fixes CodeRabbit docstring coverage warning (0% -> should meet 80% threshold)
* fix: address CodeRabbit review issues
- Fix worktree-scheduler.md: correct parallel example (different workspaces)
- Fix worktree-scheduler.md: add language specifier to code block
- Fix task/promote.md: use default_branch instead of development_branch (matches config)
- Fix task/validate.md: add guard to prevent PRs when defaultBranch = main
- Fix sdd-pr-create-or-update skill: use correct config key names (default_branch/production_branch)
- Fix pr-policy.yml: add validation for required config fields
- Fix pr-policy.yml: use env vars for PR data (security)
- Fix sdd-scheduler.cjs: add dependency cycle detection
- Fix sdd-scheduler.cjs: improve touchesGlobalLock with tag check
* feat: Linear agent-ok Cloud Agent delegation pilot (#7)
* feat: add Linear agent-ok Cloud Agent delegation pilot
- Create Linear sync config (work/linear/sync-config.md)
- Add Linear helpers + automation docs for SDD
- Add skill sdd-linear-delegate-cloud-agent (strict @cursor prompt)
- Add /task/delegate command and integrate delegation into /task/start and /task/batch
- Update CI to require work/backlog only on staging (or when present)
- Document pilot guardrails in openmemory + milestone checklist
* fix: avoid shell interpolation of PR body in PR policy
- Pass pull_request.body via env to prevent bash interpreting backticks
- Keeps PR description validation while avoiding injection/errors
* feat(cms): M2 — Payload CMS with content models and homepage builder (#8)
* feat(cms): scaffold Payload v3 CMS with Supabase Postgres adapter
- Add Payload v3 Next.js app in apps/cms workspace
- Configure postgres adapter with schemaName: 'payload' for isolation
- Set up Users and Media collections as baseline
- Add REST API and GraphQL routes
- Configure monorepo workspace (pnpm-workspace.yaml, root package.json)
Task: t2.1
* feat(cms): add content types, PDP guidance, and homepage builder
- Add Pages collection with SEO meta, slug auto-generation, drafts
- Add Articles collection with categories, featured image, author relation
- Add Navigation global for main menu and CTA button
- Add Footer global with link columns, legal links, social links
- Add ProductGuidance collection with skin types, concerns, ingredients,
how-to steps, AM/PM routine, and pair-with recommendations
- Add Homepage global builder with 8 section types, max 10 sections:
hero, featured-products, categories, testimonials, content-block,
newsletter, blog-carousel, brands-banner
Tasks: t2.2, t2.3, t2.4
* fix(cms): address CodeRabbit review suggestions
- Add baseUrl to tsconfig.json for valid paths config (TS5064)
- Add production check for PAYLOAD_SECRET env var (security)
- Remove redundant status field from Pages collection (use built-in _status)
* fix(cms): add validation to Navigation link fields
Add conditional validate functions to page and url fields in Navigation
global to prevent saving items with empty link targets. UI conditions
only hide fields but don't prevent invalid data from being saved.
CodeRabbit: PRRT_kwDORB5x-M5rSHX9
* Upgrade batch-runner and task/batch workflow
- Add pre-flight checks (environment, dependencies, database, git state)
- Add Linear integration with status updates per task and retry queue
- Add comprehensive error handling (retry/skip/abort, cleanup)
- Improve progress reporting (real-time updates, time estimates)
- Add dependency validation before each task
- Add branch naming strategy (milestone-level or per-task)
- Add post-execution validation (build, tests, environment)
- Add state tracking with checkpoints and resume support
- Improve cleanup logic with branch deletion confirmation
- Add confirmation gates in task/batch (execution plan, PR strategy, merge strategy)
- Add pre-flight validation step in task/batch
- Add error handling instructions for batch-runner
* feat: M3 Commerce + M4 Storefront scaffolding (#9)
* chore: update .gitignore with OpenMemory rules and node_modules
- Add .cursor/rules/openmemory.mdc to ignore list (IDE-specific)
- Add node_modules to root gitignore
* feat(commerce): scaffold Medusa backend (t3.1)
- Add apps/commerce with Medusa v2 project structure
- Configure medusa-config.ts with Redis modules:
- Event bus (BullMQ)
- Workflow engine
- Caching
- Locking
- Configure Supabase PostgreSQL with schema separation (medusa)
- Add seed script for initial data (sales channel, regions, shipping)
- Update env.example with Medusa variables
- Install Medusa dependencies
Part of M3 milestone - Commerce (Medusa): core domain + DB wiring
Refs: GUA-13
* feat(storefront): scaffold Next.js with i18n routing (t4.1)
- Create apps/storefront with Next.js 16, TypeScript, Tailwind CSS
- Implement /da and /en locale routing with middleware
- Add i18n configuration with Danish (default) and English locales
- Create dictionaries for UI translations
- Set up Medusa JS SDK integration
- Create homepage with header, hero, featured sections, footer
- Add placeholder structure for future pages
Acceptance:
- [x] /da and /en routes exist and render
- [x] Language routing strategy matches spec
Part of M4 milestone - Storefront (Next.js): MVP pages + core journeys
Refs: GUA-17
* feat(batch-2): schema separation docs + content pages
## t3.2 (GUA-14): Validate Supabase schema separation
- Document schema separation strategy (medusa schema)
- databaseSchema: "medusa" already configured in medusa-config.ts
- Add SCHEMA_SEPARATION.md with verification steps
## t4.6 (GUA-22): Content pages
- Add blog index and article pages (/[locale]/blog)
- Add policy pages (terms, privacy, cookies, returns)
- Add support pages (FAQ, contact)
- All pages support da/en locales with translations
Part of M3+M4 batch execution
Refs: GUA-14, GUA-22
* feat(commerce): implement core commerce flows (t3.3)
- Update medusa-config.ts to support development without Redis
- Uses in-memory modules when REDIS_URL is not set
- Production uses Redis-based event bus and workflow engine
- Enhance seed script with comprehensive test data:
- Sales channel, stock location, shipping profile
- Denmark + Europe regions with currencies
- Product categories (skincare, cleansers, serums, etc.)
- 4 test products with variants and prices
- Inventory items with stock levels
- Update env.template with detailed documentation
Medusa provides Store API out-of-the-box:
- GET /store/products - List products
- POST /store/carts - Create cart
- POST /store/carts/:id/line-items - Add to cart
- Full checkout flow with payment collections
Acceptance:
- [x] Products can be fetched/listed
- [x] Cart can be created/updated
- [x] Checkout can create an order (in local/test flow)
Refs: GUA-15
* feat(batch-4): accounts baseline + PLP pages
## t3.4 (GUA-16): Accounts baseline
- Document guest checkout flow (Medusa out-of-box)
- Document authenticated checkout for subscriptions
- Add CHECKOUT_FLOWS.md with implementation notes
- Frontend gating recommended for MVP (prompt login for subs)
## t4.2 (GUA-18): PLP + search + filters
- Categories index + detail pages (/categories, /categories/[handle])
- Brands index + detail pages (/brands, /brands/[handle])
- Concerns index + detail pages (/concerns, /concerns/[handle])
- Search results page (/search) with query + category filters
- Canonical URLs on all PLP pages (per spec)
- Sort dropdown (featured, price, newest)
- Filter UI placeholders
All pages support da/en locales with proper metadata.
Refs: GUA-16, GUA-18
* feat(storefront): implement PDP with subscription selector (t4.3)
## Product Detail Page
- Full product page layout with image gallery placeholder
- Variant selector (size/options)
- Breadcrumb navigation
## Subscription Selector Component
- One-time vs subscription toggle
- 4, 8, 12 week cycle options
- 5% subscription discount display
- Benefit list (save %, flexible delivery, cancel after 2)
- Full da/en localization
## Product Guidance (from CMS)
- Skin types tags
- Target concerns
- Key ingredients with benefits
- How to use instructions
- When to use (AM/PM badge)
- Pair with product recommendations
Ready for Medusa + Payload data integration.
Refs: GUA-19
* feat(storefront): cart, checkout + account area shell (t4.4, t4.5)
## t4.4: Cart + Checkout Shell
- Cart page with line items, quantity controls, subscription badges
- Order summary with subtotal, shipping, total
- Checkout page with multi-step progress (contact, shipping, payment)
- Shipping address form
- Shipping method selection
- Payment placeholder for Adyen integration
- Full da/en localization
## t4.5: Account Area Shell
- Account dashboard with stats and navigation
- Orders page with status badges and order history
- Subscriptions page with management controls:
- Skip next delivery
- Pause/resume subscription
- Change frequency
- Cancel (after minimum commitment)
- Benefits reminder card
- Full da/en localization
## i18n Updates
- Added account section to dictionaries
- Added cart.summary and checkout.contact fields
- Updated Dictionary type interface
Refs: GUA-20, GUA-21
* fix(commerce): schema separation + idempotent seed script
- Fixed database schema separation (134 tables now in medusa schema)
- Made seed script fully idempotent (safe to run multiple times)
- Added @medusajs/admin-shared dependency for admin dashboard
- Key fix: ALTER DATABASE/ROLE SET search_path TO medusa, public
Closes: t3.2 (schema separation validation)
* chore: mark M3 + M4 tasks as done
Completed tasks:
- t3.1: Scaffold Medusa server + worker
- t3.2: Schema separation (medusa schema)
- t3.3: Core commerce flows
- t3.4: Accounts baseline
- t4.1: Next.js storefront + i18n
- t4.2: PLP + search pages
- t4.3: PDP + subscription selector
- t4.4: Cart + checkout shell
- t4.5: Account area shell
- t4.6: Content pages
* fix: address CodeRabbit review comments
- Add production guard in medusa-config.ts (REDIS_URL, JWT_SECRET, COOKIE_SECRET)
- Pin @medusajs/* dependencies to exact version 2.13.1
- Fix seed.ts: parent_category_id for child categories
- Fix storefront: formatPrice currency, statusLabels fallback, date formatting
- Fix URL encoding in search page
- Preserve query params in middleware redirects
- Fix typo in blog slug page (ingredienserper → ingredienser)
- Remove unreachable empty-state check in categories page
- Update cookie policy to reflect unimplemented trackers
- Update README with correct locale path
Addresses CodeRabbit actionable comments
* ci: add test jobs for commerce and storefront typecheck
* fix: address remaining CodeRabbit comments
- Remove continue-on-error from commerce unit tests in CI
- Fix undefined categoryNames in seed summary (use childCategoryNames)
* fix(ci): make test-commerce and typecheck-storefront pass
- Add --passWithNoTests to commerce test:unit (no tests yet)
- Update pnpm-lock.yaml to match pinned @medusajs/* versions in commerce
* fix(ci): use pnpm version from package.json (packageManager)
Remove hardcoded version: 8 to avoid conflict with packageManager: pnpm@9.15.0
* fix(commerce): idempotent seed – sales-channel link + inventory for all test products
- Always link sales channel to stock location (even when location already exists)
- Ensure inventory for all test products (created + existing), not only newly created
- Addresses CodeRabbit unresolved comments
* chore: restore spec commands (init, refine, plan, audit, evolve, sync)
Co-authored-by: Cursor <cursoragent@cursor.com>
* spec + backlog: design source (Figma repo), M6 milestone, t6.1–t6.8 tasks
- spec/05: design source decision (Ecommercestorefrontdesign)
- spec/07: design implementation source reference
- spec/03: Figma export code quality risk
- milestones: M6 Storefront design system-integration
- tasks: M6 section with t6.1–t6.8 (journey-based: Discovery, Purchase, Account & content, data+touch-ups)
Co-authored-by: Cursor <cursoragent@cursor.com>
* t6.1: design repo as submodule at design/Ecommercestorefrontdesign, document in spec/05
Co-authored-by: Cursor <cursoragent@cursor.com>
* t6.2: design-repo analysis (structure, components, tokens, mockup, sitemap mapping)
Co-authored-by: Cursor <cursoragent@cursor.com>
* t6.3: theme/tokens in globals.css, root layout uses tokens, spec/07 primary confirmed
Co-authored-by: Cursor <cursoragent@cursor.com>
* t6.4: core UI (Header, Footer, Button, Card, Input) token-driven, focus-border, layout integrated
Co-authored-by: Cursor <cursoragent@cursor.com>
* t6.5–t6.8: Home/PLP/Checkout design integration, tokens, layout; M6 tasks done
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: alle sider bruger shared layout + design tokens (fjern duplikerede header/footer)
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(M6): checkpoint design 1:1 — update spec, gap analysis, backlog, Linear
- spec/07-design-system.md: add M6 implementation status (checkpoint) and next phase (backend data + wiring)
- DESIGN-TO-STOREFRONT-GAP-ANALYSIS.md: add section 6 (næste fase: backend data + storefront wiring)
- work/backlog/tasks.local.md: M6 checkpoint note; new M6 data/wiring tasks t6.d1–t6.d3 (Medusa seed, Payload handles, Storefront wiring) with Linear GUA-48/49/50
- Linear: created GUA-48 (t6.d1), GUA-49 (t6.d2), GUA-50 (t6.d3) in Backlog for next phase
Co-authored-by: Cursor <cursoragent@cursor.com>
* [M7] Payload–Medusa sync, simplified Products, SkinTypes/Concerns (#10)
* fix(cms): t7.1 Payload opstart + admin layout (GUA-51)
- Root layout: kun children; (site) route group med html/body
- (payload)/layout: import @payloadcms/next/css; custom.scss overrides
- DATABASE_URL check, env.template, migrations + payload CLI
- PAYLOAD_SECRET build placeholder; @payloadcms/ui direct dep
- README + spec/03-risks; tasks.local + milestones M7
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(t7.2): Payload E2E checklist + schema verification (GUA-53)
- work/backlog/t7.2-log.md: E2E checklist (login, 5 collections, 3 globals, persistence)
- Supabase schema payload verified via MCP (all 8 tables present, writable)
- tasks.local.md t7.2 done; Linear GUA-53 Done
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(t7.2): update log with Done status and final entry
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(cms): t7.3 i18n + SEO requirements + Articles meta.image (GUA-52)
- apps/cms/docs/i18n-seo-requirements.md: i18n da/en, SEO meta, structured data
- Articles: add meta.image (upload) + admin descriptions for SEO/social
- work/backlog/t7.3-log.md, tasks.local.md t7.3 done; Linear GUA-52 Done
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(cms): reference Payload SEO plugin and Localization in i18n-seo-requirements
- Add Official Payload features: @payloadcms/plugin-seo, Localization (content) vs I18n (admin UI)
- Align i18n options with Payload Localization (built-in), not 'i18n plugin'
- Note SEO plugin for future migration (generateTitle/Description/Image, preview)
- Future work: Localization + SEO plugin (t7.4)
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(cms): t7.4 Payload v3 plugins research (GUA-54)
- apps/cms/docs/payload-plugins-research.md: SEO, Localization, storage-s3/Supabase
- Anbefalinger: SEO plugin M7 eller kort efter; Localization når da/en; storage når media i Supabase
- work/backlog/t7.4-log.md, tasks.local.md t7.4 done; Linear GUA-54 Done
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(commerce): product_type, categories with rank, product_tags (GUA-55)
- Seed product types: cleanser, toner, serum, moisturizer, SPF, eye cream, face mask
- Seed product tags: skin types + concerns (oily, dry, acne, hydration, etc.)
- Categories: Skincare parent + 7 children with rank 0–6; update rank on existing
- Products get type_id, categories, tags via create/update workflows
- Idempotent seed; t7.5 done
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(commerce): product metadata brand, primary_skin_type, primary_concern (GUA-56)
- Brand + primary skin type/concern in product.metadata (not collection)
- Docs: apps/commerce/docs/product-metadata-decisions.md
- Seed: metadata on all four test products; create + update; idempotent
- t7.6 done
Co-authored-by: Cursor <cursoragent@cursor.com>
* M7: Payload–Medusa integration (t7.8–t7.13)
- Env + CMS Medusa proxy: MEDUSA_STORE_URL, /api/medusa/*, 5min cache
- Commerce GET /store/brands
- Collections: Products, Ingredients, Routines, Beneficials, Categories, Brands
- Homepage globals: relationships til Products/Categories/Brands + fallbacks
- Docs: spec/10, medusa-proxy.md; backlog notes + t7.8–t7.13 log
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(commerce,cms): Payload–Medusa sync for products, categories, brands, product types
- Commerce: Payload module (API client), workflows + subscribers for sync
- Commerce: Admin Settings > Payload with sync buttons and status
- CMS: Categories, Brands, ProductTypes collections; access + medusa_id
- CMS: Categories sync with unique handles, name fallback, parent hierarchy
- CMS: create-payload-items step: strict body, dedupe handles, parent as number
- Docs: payload-medusa-sync-plan, payload-api-key-for-medusa, data-model
- Spec: 10-cms-commerce-synergy and related updates
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(cms): add localization (da/en) and i18n admin UI
- payload.config: localization (da default, en), i18n with @payloadcms/translations
- Collections/globals: localized fields (Pages, Articles, Products, Categories, Brands, ProductTypes, Media, ProductGuidance, Ingredients, Routines, Beneficials, Navigation, Footer, Homepage)
- Migrations: 20260205_163856, 20260205_164613, 20260205_165847 (locale tables + baseline doc)
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(M7): Payload–Medusa sync, simplified Products, SkinTypes/Concerns
- Products: simplified schema (skinTypes, concerns, ingredients), update sync only sku/ean
- Categories: create+update sync, never overwrite localized name (handle, parent only)
- Add SkinTypes + Concerns collections; remove ProductGuidance, Routines, Beneficials
- Sidebar: Product Content group, order Products→Categories→Brands→ProductTypes→SkinTypes→Concerns→Ingredients
- Commerce: update-payload-products, update-payload-categories workflows
- CMS: fix React 19/Next.js 15 LayoutProps type in layout.tsx; remove unused generateTitle param
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: address CodeRabbit PR #10 feedback
- Align Payload packages to 3.74.0 (payload, db-postgres, next, plugin-seo, richtext-lexical, storage-s3, translations, ui)
- Harden isFromMedusa: only accept x-medusa-sync-secret header in production; query param allowed in development only (Products, Categories, Brands, ProductTypes)
- Fix pagination in products-sync-payload: use hasMore = products.length === BATCH instead of offset < total when metadata.count is undefined
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: address CodeRabbit PR #10 round 2 feedback
- Extract isFromMedusa to shared lib/access.ts; import in Products, Categories, Brands, ProductTypes
- Brands: add readOnly to brandKey, validate brandKey on update when changed
- Categories/Products: remove invalid defaultValue from tabs fields
- Categories: add comment documenting fail-open behavior when Medusa unavailable
- medusaProductHandleExists: add console.warn when Medusa returns empty (validation bypass)
- Products: add unique: true to medusa_id for 1:1 Medusa mapping
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* GUA-50 M6 — Storefront wiring (Medusa + Payload) (#11)
* feat(storefront,commerce): PDP reviews, breadcrumbs, brands & Medusa integration
- Commerce: @lambdacurry/medusa-product-reviews plugin, custom POST /store/product-reviews/submit (auth required, no order)
- Storefront: Product reviews section (Matas-style layout, star distribution, review form); submit via /api/product-reviews (auth in separate task)
- PDP: Breadcrumbs Hudpleje/Skincare > Category > Product; category from Medusa
- Commerce: Brand module, store/admin brands API, product-brand link, Payload sync
- Storefront: Medusa brands/categories/products libs, PLP sort, variant selector, quantity selector, payload product by handle
- CMS: Product migrations (description, subtitle), storefront product API, clean-brands
- i18n: reviews labels, breadcrumbRoot (Hudpleje/Skincare)
Auth (login + token for review submit) to be implemented in a follow-up task.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: address Code Rabbit PR review (security, bugs, UX, migrations, prices)
- Security: clean-brands auth via header only, require SECRET in prod; strip PII email from reviews; remove debug-payload route
- Bugs: medusa-categories don't cache null; product-brand-ingredients fallback on remove; emit brand.deleted after deleteBrands; validate brand name/link; validate new brand before dismiss
- CMS migration: include da/en in title/subtitle WHERE
- UX: Footer bg-surface; Header Heart aria-label Konto; brands [handle] aria-label Breadcrumb; hide brand productCount when 0
- Prices: document major units; mock data in cart/checkout/orders/subscriptions use major units
- create-payload-products: remove unused brand.handle; fetchProductsByCategory/Brand: pass order param; Next.js 16.1.6
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: update pnpm-lock after Next.js 16.1.6 (fix CI frozen-lockfile)
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): pin @medusajs/ui to 4.1.1 for Railway npm build
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): add Dockerfile so Railway includes medusa admin build (.medusa)
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): disable Medusa admin by default in production so server starts on Railway
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(commerce): add Railway config (railway.toml + railway-worker.toml, beauty-shop pattern)
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): disable Railway healthcheck (Medusa has no /health)
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): Dockerfile validate admin build, NODE_ENV=production; doc index.html troubleshooting
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): do not set NODE_ENV=production in Docker builder (Medusa requires REDIS/JWT/COOKIE at build else)
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): validate dist/public/admin and copy to public/admin for runtime loader
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(storefront): force-dynamic for brands/categories to avoid build timeout on Railway
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): pass MEDUSA_BACKEND_URL at Docker build so admin login uses correct API URL
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): move ARG/ENV after COPY in Dockerfile to avoid circular stage dependency
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): rename Docker stage deps to install-deps to avoid Railway circular dependency
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(commerce): add Plunk Next API for invite emails (transactional email)
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(commerce): add invite-email success log and Plunk troubleshooting doc
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(commerce): strip leading slash from admin path in invite URL
Made-with: Cursor
* Plan: M8 Auth, M9 Stripe, M10 Shipmondo + spec Adyen→Stripe
- milestones.md: add M8 (Customer Auth), M9 (Stripe), M10 (Shipmondo); update M5 scope
- tasks.local.md: add M8/M9/M10 tasks; cancel t5.1–t5.3 (replaced by M9/M10); add Linear refs for t5.4–t5.9
- spec: switch payment provider from Adyen to Stripe (00, 01, 02, 08, openmemory.md)
- spec/03-risks.md: add auth/payment/shipping risks; update time-to-market to Stripe
Made-with: Cursor
* M8 — Customer Authentication (#12)
* chore(M8): add Linear IDs GUA-64–69 to M8 tasks in tasks.local.md
Made-with: Cursor
* feat(M8/t8.1): configure Medusa Auth module (emailpass + Google)
- Add auth module with emailpass and google providers in medusa-config.ts
- Document GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URL in env.template
Made-with: Cursor
* feat(M8/t8.2): add login page (email/password + Google)
- /[locale]/login with email/password form and Log ind med Google button
- LoginForm client component using medusa.auth.login (emailpass + google)
- Auth i18n keys (da/en)
Made-with: Cursor
* feat(M8/t8.3): add register page (email, password, name via Medusa Store API)
- /[locale]/register with form; auth.register + store.customer.create
- Register auth i18n keys
Made-with: Cursor
* feat(M8/t8.4): add Google OAuth callback page
- /[locale]/auth/google/callback: validateCallback, create customer if new, refresh, redirect to account
Made-with: Cursor
* feat(M8/t8.5): auth state management and protected account routes
- AuthProvider + useAuth (customer, loading, isAuthenticated, refetch)
- AccountGate: redirect to login when not authenticated
- Account layout wrapped with AccountGate
Made-with: Cursor
* feat(M8/t8.6): wire account sign out and customer data
- AuthContext.signOut() clears session via medusa.auth.logout
- AccountHeader: sign out button + show customer email
- Unauthenticated redirect already handled by AccountGate
Made-with: Cursor
* docs(M8): add Google OAuth setup guide and env.template pointer
- GOOGLE-OAUTH-SETUP.md: full guide for Client ID/Secret and redirect URIs
- env.template: short pointer to guide for GOOGLE_* vars
Made-with: Cursor
* fix: address CodeRabbit review (OAuth locale, register flow, callback email, i18n, a11y, AuthContext)
- LoginForm: pass callback_url with locale for Google OAuth; auth.orDivider i18n
- RegisterForm: skip customer.create when existing user (shouldCreateCustomer flag)
- Google callback: require valid email when shouldCreateCustomer, throw otherwise
- AccountGate: loadingLabel prop, role=status aria-live=polite, dict.account.loading
- AuthContext: refetch sets loading true; request version guard vs signOut; useAuth throws outside provider
- env.template: note that storefront passes callback_url per request
Made-with: Cursor
* fix(docs,auth): CodeRabbit – Google Identity Services + account layout comment
- GOOGLE-OAUTH-SETUP: replace deprecated Google+ API with Google Identity Services / People API
- account/layout: document client-side auth (AccountGate/JWT); no server-side redirect
Made-with: Cursor
* M9: Stripe Payments – t9.1–t9.6 (checkout, webhook, E2E) (#13)
* M9: scope + subscription tasks (t9.7–t9.9) + m9-linear-log
Made-with: Cursor
* M9: Linear issues GUA-87–GUA-95 oprettet og knyttet til tasks
Made-with: Cursor
* chore(t9.6): replace Adyen with Stripe in spec files and env inventory
- spec/03, 04, 05, 06, 09: Adyen → Stripe
- env.example: Adyen vars → Stripe vars
- CheckoutSteps placeholder text: Adyen → Stripe
- payload-plugins-research, DESIGN-TO-STOREFRONT-GAP: Adyen → Stripe
Made-with: Cursor
* feat(t9.1): add Stripe Payment Module to medusa-config + env vars
- Register @medusajs/medusa/payment-stripe when STRIPE_API_KEY is set
- Document STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET in commerce env.template
- Document NEXT_PUBLIC_STRIPE_KEY in storefront env.template
Made-with: Cursor
* feat(t9.2): enable Stripe in Denmark region via seed + doc
- Seed: when STRIPE_API_KEY set, enable pp_stripe_stripe for Denmark
- Update existing Denmark region with updateRegionsWorkflow
- Add STRIPE-SETUP.md with env vars and Admin alternative
Made-with: Cursor
* feat(t9.3): integrate Stripe PaymentElement in checkout
- Add @stripe/react-stripe-js, @stripe/stripe-js
- CheckoutWithStripe: create cart, initiate payment session, mount PaymentElement
- StripePaymentForm: PaymentElement + confirmPayment with return_url
- API /api/checkout/init: fetch region_id and variant_id from Medusa
- Order confirmation page: complete cart on return from Stripe, redirect to order
- CheckoutSteps: paymentContent prop, onStepChange for step 3
Made-with: Cursor
* docs(t9.4): document Stripe webhook for deployed environments
Medusa Stripe module provides /hooks/payment/stripe_stripe.
Document URL, events, STRIPE_WEBHOOK_SECRET in STRIPE-SETUP.md
Made-with: Cursor
* docs(t9.5): add E2E payment test checklist; M9 subscription todo for t9.7–t9.9
Made-with: Cursor
* chore: update M9 task statuses (t9.1-t9.4,t9.6 done; t9.5 in progress)
Made-with: Cursor
* M9: t9.5 done – E2E Stripe checkout + shipping fix, tasks/Linear sync
- Checkout uses cookie cart, adds shipping method, completes order
- Seed: fulfillment provider + service zone + shipping option (DKK)
- Cart: getCart/getCartId, CartItems, ProductPurchaseSection
- Checkout page: real cart data, cartId prop
- tasks.local.md: t9.5 status → done
- m9-linear-log.md: t9.5 → Done
- Linear GUA-91 set to Done
Made-with: Cursor
* Address CodeRabbit review: cart/seed/checkout/i18n/format
- cart.ts: single res.json() in addToCart; seed: remove duplicate query, improve link catch
- OrderConfirmationComplete: dict for confirming/error/backToCheckout, remove orderId, abort guard + log
- ProductPurchaseSection: timer ref + cleanup; localized aria-labels via dict
- CartItems: CartItem type, formatPrice from @/lib/format, aria-labels from dict.cart
- Checkout page: cartId from cart?.id, CartItem[], shared formatPrice
- CheckoutWithStripe: retry when clientSecret null; CheckoutSteps: gate Place Order link
- next.config: Supabase host from NEXT_PUBLIC_SUPABASE_PROJECT_ID
- i18n: orderConfirmation + products + cart keys for accessibility and copy
Made-with: Cursor
* CodeRabbit follow-up: seed sync/guards, formatPrice Intl locale
- seed: Always sync Denmark payment_providers (enable + disable when key removed)
- seed: Guard empty locData from query.graph; check 'Standard Levering' by name
- format: Map route locale (da/en) to Intl locale (da-DK/en-US) in formatPrice
Made-with: Cursor
* docs(backlog): post-merge note M9 PR #13, workspace ready for t9.7–t9.9
Made-with: Cursor
* M10 — Shipmondo Shipping (t10.1–t10.5 + fix) (#14)
* docs(commerce): add SHIPMONDO.md API research (t10.1 / GUA-76)
- Auth (Basic with API user + key), sandbox vs production URLs
- Endpoints: pickup_points, products, shipments (create, get, list)
- Product codes GLSDK_SD (GLS Pakkeshop), request/response examples
- Env vars and error handling notes
Made-with: Cursor
* feat(commerce): add Shipmondo Fulfillment Module Provider (t10.2 / GUA-77)
- src/modules/shipmondo/service.ts: AbstractFulfillmentProviderService
- getFulfillmentOptions (GLS + DAO pakkeshop), validateOption, validateFulfillmentData
- calculatePrice 39 DKK flat, createFulfillment (Shipmondo API), getFulfillmentDocuments
- cancelFulfillment, createReturnFulfillment stub
- src/modules/shipmondo/index.ts: ModuleProvider FULFILLMENT
- fix seed.ts logger.warn signature for build
Made-with: Cursor
* feat(commerce): register Shipmondo in medusa-config + env (t10.3 / GUA-78)
- Fulfillment module with manual + Shipmondo providers (when SHIPMONDO_* set)
- env.template: SHIPMONDO_API_USER, SHIPMONDO_API_KEY, SHIPMONDO_SANDBOX
- seed: link shipmondo_shipmondo + create Pakkeshop (39 kr) option
Made-with: Cursor
* feat(checkout): pakkeshop search and selection in checkout (t10.4 / GUA-79)
- Commerce: GET /store/pickup-points proxy to Shipmondo (carrier_code, zipcode)
- Storefront: fetchPickupPoints(), CheckoutSteps parcel UI (zipcode search, list, select)
- CheckoutWithStripe: shipping options state, selected option + data, addShippingMethod with data
- Parcel option shows 39 DKK; selected pakkeshop stored on cart via provider data
Made-with: Cursor
* docs(commerce): add E2E test steps for M10 shipping flow (t10.5 / GUA-80)
Made-with: Cursor
* fix(M10): cart clear, guest enrichment, pakkeshop-only, shipping address
- Cart: clear cartId after order, getOrCreateCart skips completed carts
- Guest: order-placed subscriber enriches guest with name/phone from shipping
- Pakkeshop-only: remove Standard/Ekspres from seed, default in checkout
- Shipping address: pakkeshop address when pickup; user address for billing
- Pickup points: Shipping Module Key + fallback to Basic Auth on 422
- Docs: SHIPMONDO.md dry-run, sandbox, env.template updates
- Backlog: t10.1-t10.5 done, t10.6 sandbox-test in progress
Made-with: Cursor
* fix: type-check, CodeRabbit review (pickup validation, timeout, sanitize logs, appliedShipping)
- cart: StoreCart type, getCart return type
- pickup-points route: query validation, carrier whitelist, limit 1-50, 8s timeout
- shipmondo: 10s timeout, truncate error body 300 chars, service_point postal/city first
- seed: throw on Shipmondo link fail
- CheckoutWithStripe: appliedShippingOptionId, validate selection vs opts
- pickup-points lib: try/catch fetch/json return []
Made-with: Cursor
* fix: CodeRabbit review (SHIPMONDO docs, clearCartId try/catch, pickup Basic Auth guard, Shipmondo types, address placeholders)
- SHIPMONDO.md: http code block, credentials note corrected
- OrderConfirmationComplete: clearCartId in try/catch, continue on success
- pickup-points: guard apiUser/apiKey before Basic Auth
- Shipmondo: getFulfillmentDocuments Promise<{name,url}[]>, retrieveDocuments returns
- CheckoutWithStripe: "—" → "" for address placeholders
Made-with: Cursor
* fix(shipmondo): revert to base class signatures (Promise<never[]>, Promise<void>)
Made-with: Cursor
* fix: CodeRabbit (carrier_code precedence, formData re-sync, address validation)
- pickup-points: add parentheses for ?? operator precedence
- CheckoutWithStripe: track lastAppliedFormData, re-run when address changes
- CheckoutWithStripe: validate address/postal/city before payment when no pakkeshop
- Clear paymentError on success
Made-with: Cursor
* fix(CheckoutWithStripe): validate billing address regardless of pakkeshop selection
Made-with: Cursor
* M9: Subscription recurring – admin, cart, renewal, design (#15)
* M9: Subscription recurring – admin, cart, renewal, design alignment
- Commerce: subscription module, admin UI (list/detail/widget), store/admin API,
renewal + retry + expiration jobs, renew workflow, simulate script, react-router-dom + icon fixes
- Storefront: cart redesign (pill buttons, subscription toggle, oversigt), cart-utils split,
CheckoutAuthGate/CartCheckoutGate, Button rounded-full + ProductPurchaseSection uses Button,
i18n cart keys, setLineItemSubscription action
Made-with: Cursor
* fix: address CodeRabbit PR review comments
- Badge: use color orange for skip-next (valid @medusajs/ui value)
- Cart: displayTotal from cart.total, fallback subtotal+shipping; Math.max(0, total - subscriptionDiscountAmount)
- CartItems: lineTotalOriginal from item.total ?? unitPrice*quantity
- ProductPurchaseSection: SubscriptionSelector basePrice from selected variant
- setLineItemSubscription: try/catch with rollback addToCart on failure
- subscriptions.ts: 8s fetch timeout; comment on cookie forwarding
- AuthModal: focus trap (save/restore focus, Tab cycle), first focusable on open
- run-subscription-renewal: comment on Stripe amount (minor units/øre)
- subscription service: TODO for atomic delivery_count/next_renewal_at updates
Made-with: Cursor
* fix: address new CodeRabbit PR comments
- Discount consistency: default 20% for subscriptions (model + order-placed
subscriber) to match storefront SUBSCRIPTION_DISCOUNT_PERCENT
- AuthModal: add closeLabel to labels (i18n da/en), use for close button aria-label
- AuthModal: restore focus after modal fully closed (separate effect when !isOpen && !exiting),
add tabIndex=-1 on dialog, no restore in trap cleanup
- subscriptions.ts: encode id path segment (encodeURIComponent) for fetch URL
Made-with: Cursor
* fix: validate cycleWeeks and add per-item error isolation in subscription creation
- Validate subscription_cycle: must be number, integer, and > 0; skip and warn otherwise
- Wrap create + link in try/catch per item so one failure does not stop the loop; log errors
Made-with: Cursor
* Add .agents/ folder for use across branches
Made-with: Cursor
* chore(cursor,agents): project rules + Payload, Stripe, refactor skills
- Cursor rules: architecture, medusa-commerce, payload-cms, storefront-next,
security, code-structure, nextjs-react-typescript, front-end
- Agent skills: payload, refactor, stripe-best-practices, stripe-integration
Made-with: Cursor
* feat(m11): split commerce backend core changes
Isolates Medusa backend runtime changes (API/modules/lib/jobs/subscribers) from the M11 branch into a reviewable PR under the CodeRabbit file limit.
Made-with: Cursor
* feat(m11): split commerce admin and ops tooling
Isolates Medusa admin routes, scripts, and operational docs from M11 into a smaller review unit for CodeRabbit and domain-focused reviewers.
Made-with: Cursor
* feat(m11): split cms content APIs and schema changes
Extracts Payload schema, fields, and storefront content API changes into a dedicated review PR for CMS-content ownership.
Made-with: Cursor
* feat(m11): split storefront routes and core libs
Moves storefront route handlers, app routes, state contexts, i18n, and core lib updates into a focused PR under CodeRabbit limits.
Made-with: Cursor
* feat(m11): split storefront account checkout cart components
Separates storefront commerce-oriented UI components (account, checkout, cart, subscriptions) into an isolated reviewable PR.
Made-with: Cursor
* feat(m11): split storefront content UI and design assets
Groups content-first storefront components, presentation UI, and design/public assets into a standalone PR for visual/content review.
Made-with: Cursor
* chore(m11): split ai skills, rules, and project metadata part 2
Splits remaining AI skills, Cursor rules, root metadata, lockfiles, and backlog/spec updates for incremental review and merge.
Made-with: Cursor
* fix(m11): address PR17 CI and renewal safety concerns
Updates lockfile for newly introduced commerce dependencies, hardens Stripe client retry behavior, and improves subscription idempotency plus renewal failure compensation semantics.
Made-with: Cursor
* test(m11): include commerce Jest config in backend split PR
Adds the missing Jest transformer config required for TypeScript unit tests in the commerce backend split, fixing CI parse failures.
Made-with: Cursor
* chore(m11): keep backend split PR within review file limit
Moves a non-critical TS env declaration out of PR17 so CodeRabbit can process the split PR within its file threshold.
Made-with: Cursor
* fix(subscriptions): enforce atomic idempotency per order line
Adds a DB-backed idempotency key with a unique partial index and updates order placement subscription creation to be race-safe on order_id + line_item_id conflicts.
Made-with: Cursor
* fix(m11): restore subscription hardening and stabilize admin ops flows
Recover critical subscription reliability safeguards (idempotency, migrations, stripe client hardening, renewal claim/refund protection) and resolve PR18 admin and script robustness issues so CI/ops behavior is explicit and safer.
Made-with: Cursor
* fix(m11): address latest PR18 wizard and refresh races
Apply follow-up CodeRabbit fixes by hardening Shipmondo wizard session invalidation, aligning sender normalization across preview/apply, preventing false-negative UI errors after successful saves, and validating free-shipping DTO payload shape before state updates.
Made-with: Cursor
* fix(m11): resolve latest accessibility and messaging review notes
Add explicit accessible labeling for free-shipping enable switch and improve Shipmondo product fetch fallback error text to be user-facing and complete.
Made-with: Cursor
* fix(m11): harden cms storefront APIs and payload validation
Resolve PR #19 review findings with runtime locale guards, draft-aware article fetching, homepage cache headers, conditional field validation, and shared navigation option cleanup while preserving CMS preview behavior.
Made-with: Cursor
* fix(m11): harden storefront redirects and upstream timeouts
Address critical PR #20 findings by adding request timeouts for free-shipping upstream calls, enforcing safe sign-out redirects, sanitizing rich-text link protocols, restoring visible keyboard focus states, and syncing pnpm lockfile with storefront dependencies.
Made-with: Cursor
* fix(m11): restore storefront components for CI and address PR20 review
- Sync apps/storefront/src/components from M11 source branch so @/components
imports resolve and typecheck passes.
- globals.css: clip-path for skip-link, search-modal :focus-visible ring, body
spacing for stylelint.
- free-shipping API: degraded payload constant, inner try/catch for no-cart
upstream, outer catch returns degraded shape when cartId is null.
- Add .stylelintrc.json ignoring Tailwind v4 at-rules for CodeRabbit/stylelint.
Made-with: Cursor
* fix(storefront): address CodeRabbit major review items
- Stylelint: use at-rule-no-unknown for Tailwind v4
- Next: Medusa host in images.remotePatterns for checkout thumbnails
- A11y: HeaderBackButton, ProfileForm, AccountLayout mobile sheet dialog
- Checkout: required ContactForm callback; noop default in CheckoutSteps
- useShippingOptions: reset selection when cart cleared or fetch fails
- usePaymentSession: fuller form fingerprint; explicit shipping when multiple options
- Pickup: no rethrow in PickupPointManager; prefill catch; sheet search seq + errors
- Content: full-width ContentBlock; blog slug guards; PromotionSlider href + labels
- PostHog: opt_in_capturing on re-consent after init
- Cart/ProductCard: subscription_cycle parsing; toast on quick-add failure
- i18n: checkout.shippingMethodRequired (da/en)
Made-with: Cursor
* fix(storefront): address CodeRabbit PR20 follow-up review
- AccountLayout: normalizePath for nav/title; close sheet on md+; focus trap + restore
- CheckoutOrderSummary: dict keys for VAT/shipping helper copy; narrow cart type
- Pickup: sync carrier when point cleared; sheet close invalidates fetch seq; keep point if still in results; safer zip fallback
- ContentBlockSection: CTA href scheme/locale; single column without image
- PromotionSlider: accessibleLabel; play/pause; focus/touch pause; lazy offscreen imgs
- ProductCard: wishlist outside image link; split add vs sync errors; viewProduct a11y; unit price ??
- i18n: checkout + promoSlider pause/play strings
Made-with: Cursor
* fix(storefront): address PR #21 CodeRabbit review (stripe, pickup, cart, payment cache)
- Reset Stripe onProcessing in finally; delivery edit goes to step 2
- Fail fast UI when NEXT_PUBLIC_STRIPE_KEY is missing
- Include email/phone/marketingOptIn in payment-session form signature
- CartDropdown: error handling for remove and clear cart
- Profile/pickup: treat refetch failure separately after successful update
- Pickup sheet hooks: stale-request guards; sync from form without clobbering search
- Expand pickup-points lib (multi-carrier fetch, distance, carrier label)
Made-with: Cursor
* fix(storefront): dict keys for cart/checkout, Header dict, geocode timeout
- Extend Dictionary cart/checkout (i18n) for CartDropdown, CheckoutWithStripe, usePaymentSession
- Pass getDictionary from locale layout to Header and CartDropdown
- Checkout page: pass full dict to CheckoutWithStripe; drop confirmationHref
- CartDropdown: dictionary for VAT/shipping lines; updateLineItem 2-arg only
- CheckoutOrderSummary: use checkout.calculatedWhenDeliverySelected and vatIncludedBreakdown
- Add lib/cart-errors; client StoreCart type in lib/cart-data
- pickup-points: 5s AbortController timeout on geocode fetch
Made-with: Cursor
* fix(storefront): restore commerce modules, pass CI typecheck
- Exclude .next from tsc; add missing lib/contexts/hooks/ui (cart, shipping, account)
- cart: clearCart, updateLineItem metadata; cart-data StoreCartItem types
- medusa: withMedusaBackendUrl; buttonVariants + cva; @base-ui/react for dialog/sheet
- Auth: signOut redirectTo, AuthModal/Login/Register returnUrl; remove unused AuthAwareShell
- i18n: cart/subscriptionDetail keys; account layout profile/addresses; subscription dict wiring
- CheckoutWithStripe, PickupPointSheet, AddToCartModal fixes
Made-with: Cursor
* fix(storefront): use relative import for cart-data in cart server module
Made-with: Cursor
* chore: exclude storefront design-assets from CodeRabbit path review
Made-with: Cursor
* fix(storefront): exclude .next from tsc (CI typecheck)
Made-with: Cursor
* chore: sync pnpm-lock.yaml with apps/commerce package.json (CI frozen-lockfile)
Made-with: Cursor
* fix(m11): address open CodeRabbit threads on docs and patch
Made-with: Cursor
* fix(commerce): resolve open PR review blockers
Made-with: Cursor
* fix(m11): restore valid review patch application
Made-with: Cursor
* chore: sync pnpm-lock.yaml with storefront package specifiers
Made-with: Cursor
* chore: sync pnpm-lock.yaml with storefront package specifiers
Made-with: Cursor
* chore: sync pnpm-lock.yaml with storefront package specifiers
Made-with: Cursor
* chore: sync pnpm-lock.yaml with storefront package specifiers
Made-with: Cursor
* fix(storefront): remove duplicate ProductPurchaseSection props
Made-with: Cursor
* fix(storefront): wrap checkout auth gate in Suspense for useSearchParams
Made-with: Cursor
* fix(storefront): suspense-wrap account gate in layout
Made-with: Cursor
* fix(admin): patch product reviews widget null image crash on install
Made-with: Cursor
* fix(commerce): copy scripts before npm install in Docker build
Made-with: Cursor
* fix(admin): guard product details widget against runtime data shape errors
Made-with: Cursor
* fix(admin): patch product review table null product/order relations
Made-with: Cursor
* fix(cms): scope global reset to site layout only
Prevent the site-level CSS reset from leaking into Payload Admin, which collapsed spacing and made the CMS UI render incorrectly.
Made-with: Cursor
* feat(commerce): add destructive purge script for subscriptions
Add medusa exec script to delete all subscription rows and product↔subscription
order links, gated by CONFIRM_PURGE_SUBSCRIPTIONS=DELETE_ALL_SUBSCRIPTIONS.
Respects DATABASE_SCHEMA for table qualification.
Made-with: Cursor
* feat(admin): Products brand overview with linked brand column
Add nested UI route under Products that lists products with GET /admin/products
and fields including +brand.* for at-a-glance brand in the admin table.
Document Payload admin CSS scoping and this list workaround in openmemory.md.
Made-with: Cursor
* fix(commerce): reconcile inventory reserved counts after order purge
Direct deletes of reservation_item left inventory_level.reserved_quantity stale.
Add SQL reconciliation (sum of active reservation_item per level) to purge-orders
and a standalone medusa exec script for one-off repair.
Made-with: Cursor
* feat(commerce): order confirmation invoice PDF + admin document downloads
- Attach invoice PDF to order confirmation email via Plunk; drop PDF links from HTML for guests
- Extend Plunk client and sendTransactionalEmail with optional attachments
- Add GET /admin/orders/:id/documents/:type and order-documents widget (order.details.side.after)
- Refactor store document route to use getOrderDocumentBase64 helper
- Update PLUNK-EMAIL.md and openmemory for worker vars and behavior
- Tighten admin tsconfig include/exclude
Made-with: Cursor
* feat(commerce): professional order PDF layout with seller config and VAT lines
- Replace minimal PDFKit text dump with branded header, two-column seller/customer, meta band, item table, totals, footer
- Add pdf-seller-config (GUAPO_INVOICE_* env), pdf-payment-label for card hints from order metadata
- Pass tax_total, discount_total, variant title from graph; da/en PDF locale from shipping country
- Document env vars in env.template; extend openmemory
Made-with: Cursor
* docs: Danish market defaults for invoice PDF env; Railway server+worker parity
- Clarify DK-focused PDFs and da/en locale in commerce env.template
- Inventory GUAPO_INVOICE_* and Plunk/STOREFRONT in root env.example
- Move Plunk + STOREFRONT + GUAPO_INVOICE to shared Railway table; note worker runs order emails/PDFs
- Update openmemory deploy note
Made-with: Cursor
* feat(commerce): PDF seller from region metadata + admin regenerate endpoint
- resolve-pdf-seller: merge Region metadata guapo_invoice over env defaults
- order-document-generation: shared buffers for subscriber + regenerate route
- POST /admin/orders/:id/documents/regenerate (auth); middleware for POST
- Admin order-documents widget: Regenerate PDFs + clearer empty state
- openmemory: document PDF storage vs regenerate flow
Made-with: Cursor
* fix(commerce): PDF region metadata flat keys + correct line amounts from graph
- resolve-pdf-seller: read guapo_invoice or same keys flat on region metadata; parse vat_rate_percent string; normalize website URL
- order-document-generation: graph raw_* + item/detail join, flattenOrderItemFromGraph + toAmountMajor; shipping_address subfields
- subscriber: subscription line detection uses flattened item metadata
- env.template + openmemory: document region vs env seller config
Made-with: Cursor
* feat(commerce): PDF seller from region only, SVG logos, refreshed layout
- pdf-seller-config: defaults without GUAPO_INVOICE env; deprecated resolvePdfSellerProfile alias
- build-order-pdf: sharp rasterizes SVG logo_url; header/cards/table polish; remove env footer hint
- resolve-pdf-seller: base from getDefaultPdfSellerProfile
- sharp dependency + lockfile; env.template, env.example, DEPLOY-RAILWAY, openmemory updated
Made-with: Cursor
* fix(commerce): PDF layout — header, seller/customer, lines, margins
- Header: vertical accent (no line through title), drop internal ref, symmetric logo
- Seller/customer: no card boxes; single CVR line; DK → Danmark; no forced https on website
- Region merge: numeric postal_code stringified for seller address line
- Table: INNER_PAD + TABLE_WIDTH for symmetric A4 columns; taller rows with optional variant subtitle
- Lines: brand + product from graph, subtitle variant; extra graph fields for variant/product/brand
Made-with: Cursor
* fix(commerce,storefront): subscription discount sync + metadata parsing
- Extract applySubscriptionLineDiscountsForCart; reuse in cart.updated subscriber
- Add POST /store/carts/:id/subscription-discount/sync for synchronous line adjustments (staging/RD)
- Normalize subscription_cycle number|string across commerce and storefront
- Call sync after addToCart (subscription) and setLineItemSubscription; improve poll
- Add inspect:order-subscription script for ops debugging
Made-with: Cursor
* fix(commerce): create subscriptions in transactional order.placed path
- Extract createSubscriptionsForPlacedOrder + listSubscriptionsForOrder
- Run creation before PDFs/emails so worker process always persists Subscription rows
- Send subscription_created email only when Subscription rows exist (avoid false positive)
Made-with: Cursor
* feat(commerce): transactional email redesign + order line thumbnails
- Replace newsletter hero with compact header; admin tone skips closing banner
- Order confirmation: line items, totals, VAT; mobile-friendly tables + optional product thumbnails
- Resolve relative thumbnail URLs via MEDUSA_BACKEND_URL or S3_FILE_URL
- Subscription created email: product, cycle, next renewal, discount from subscriptions + catalog
- Document Plunk/HTML layout in PLUNK-EMAIL and openmemory
Made-with: Cursor
* feat(commerce): dark mode styles for transactional HTML email
- prefers-color-scheme: dark overrides for shell, hero, main prose, and links
- Order summary block: line titles, totals strip, thumbnail borders
- Meta color-scheme light dark; CTA link keeps white on navy button
Made-with: Cursor
* storefront: extend product search with brand matching and scoring
- Merge Medusa q= results with products from matching brand handles
- Score and sort brands (exact name/handle, whole-word tokens, substring)
- Share brand list cache with getBrandLookup; document in openmemory.md
Made-with: Cursor
* feat(storefront,cms): harden category PLP and align Payload category SEO
- Storefront: 404 unknown category handles; Medusa-only products; Payload body + SEO metadata; cache shared loaders
- CMS: editorial Categories tabs, SEO plugin generate title/description, live preview for category PLP
- Docs: README workflow for category sync and content
Made-with: Cursor
* chore: add Cursor workspace settings
Made-with: Cursor
* storefront: hide category PLP filters behind env flag
- Add NEXT_PUBLIC_ENABLE_CATEGORY_PLP_FILTERS (default off)
- Keep getFilterCategories and FilterSystem wired when enabled
- Sort-only bar when disabled; fix empty state copy without filters
Made-with: Cursor
* fix(storefront): show Payload category SEO on PLP and normalize localized meta
- Parse flat or locale-nested meta from Payload REST
- Use absolute document title when meta.title is set (avoid double | Guapo from layout template)
- Render SEO title + description block below product grid
Made-with: Cursor
* feat(cms,storefront): video option for Image + Text (Breakout) section
Allow MP4/WebM/MOV in Media; visualType image|video in Payload block.
Storefront renders looped muted autoplay video with same layout as image.
Made-with: Cursor
* chore: add remotion-subscription-demo workspace package
Root scripts for dev/render; packages/* in pnpm workspace.
Made-with: Cursor
* fix(cms): migrate image-text-breakout visual_type and video_id columns
Staging failed listing Pages: missing DB columns after block field changes.
Adds enums + columns for pages, _pages_v, and homepage block tables.
Made-with: Cursor
* storefront: swipeable promo slider, add-to-cart loaders, cart refresh after order
Made-with: Cursor
* fix(storefront,cms): category PLP title fallback, body below grid, Payload fallback-locale
- Add fallback-locale on category fetch (align with other Payload loaders)
- H1/metadata: Payload name, else stripped SEO title, else Medusa
- Move Lexical body below product grid; SEO snippet avoids duplicate H2 vs H1
- CMS: clarify body field placement in admin copy
Made-with: Cursor
* fix(storefront,cms): reliable Payload category fetch via storefront API
- Add GET /api/storefront/category/[handle] using Local API (same as product)
- Resolve PAYLOAD base URL from NEXT_PUBLIC_PAYLOAD_API_URL or PAYLOAD_API_URL
- env.template: document both vars for Railway
Made-with: Cursor
* fix(storefront): disable Next fetch cache for Payload category enrichment
Made-with: Cursor
* fix(storefront,cms): join category PLP to Payload by medusa_id
Sync can store a suffixed handle in Payload vs Medusa URL handle; lookup by medusa_id fixes empty docs
Made-with: Cursor
* fix(storefront,cms): keep category meta title/description in head only
Remove visible SEO duplicate block; clarify Payload admin copy
Made-with: Cursor
* feat(storefront): align brand PLP with category layout and Payload SEO
Brand pages reuse category PLP structure (sort, optional filters, Lexical body
below grid) and load CMS copy via storefront brand API with medusa_id join.
Commerce sync now sends medusa_id when creating Payload brand rows. Add CMS
migration for brands.medusa_id and brands_locales.meta_image_id (SEO OG image).
Made-with: Cursor
* fix(commerce): return priced variants on store products by-brand route
Brand PLP called /store/products/by-brand without pricing context, so
calculated_price was missing and storefront showed 0 kr. Align with
GET /store/products: QueryContext from region_id, sales channel filter,
inventory and tax helpers.
Made-with: Cursor
* fix(commerce): by-brand PLP — resolve product ids via brand graph then priced query
Product-level brand handle filter returned empty results in production; load
linked ids from brand first, then graph products by id with pricing + channel.
Made-with: Cursor
* fix(commerce): align by-brand store route with core GET /store/products
Use FeatureFlag index-engine branch (query.index vs query.graph), correct
sales_channel filter shape per path, set pricingContext on request, and
wrapVariantsWithInventoryQuantityForSalesChannel like the official product list.
Removes two-step id workaround that could return empty or diverge from core.
Made-with: Cursor
* fix(commerce): brand PLP products via two-step query.graph
Made-with: Cursor
* fix(commerce): brand PLP uses product_sales_channel like core store products
Made-with: Cursor
* feat(cms): product SEO fields and PDP metadata
Add Payload plugin-seo meta group to Products (localized title, description, OG image).
Migration adds columns on products_locales. Storefront product page uses SEO for HTML
meta and Open Graph; live preview includes products.
Made-with: Cursor
* fix(commerce): brand products via product_brand link; surface storefront fetch errors
Made-with: Cursor
* fix(storefront): absolute OG image URLs and populate Payload SEO image
- CMS storefront product API: resolve meta.image when stored as id only; depth 4
- PDP metadata: metadataBase, absolute canonical/og:url, normalize image URLs
- Fallback og:image to Medusa thumbnail/gallery when CMS OG image unset
Made-with: Cursor
* fix(commerce): by-brand link query fallback; brand PLP shows API errors
Made-with: Cursor
* fix(commerce): by-brand inventory wrap with single sales_channel_id
Made-with: Cursor
* fix(commerce): brand PLP avoids inventory wrapper; return by_brand_error details
Made-with: Cursor
* fix(commerce): by-brand query.graph uses variants.* field paths
Made-with: Cursor
* fix(store): brand PLP uses product-ids then core /store/products
Made-with: Cursor
* feat: CMS GTM tracking, email preview script, launch spec updates
- Payload Tracking global + migration + storefront API; load GTM after consent
- Transactional email preview script and commerce tmp/gitignore tweaks
- Mark open questions resolved and MVP quality gates checked in spec
Made-with: Cursor
* feat(storefront): PostHog e-commerce events and pageviews
- Manual $pageview on App Router; disable default capture_pageview
- product_viewed, add_to_cart, checkout_started, order_completed (existing)
- user_registered, user_logged_in (email + Google callback)
- search_results_viewed, search_submitted (modal, page form, results)
- wishlist_updated, wishlist_page_viewed; cart_viewed (page, drawer, modal)
- identify/reset on login state; medusaProductId on PLP cards
Made-with: Cursor
* docs(storefront): add PostHog setup guide and register locale super property
Made-with: Cursor
* storefront: simplify search modal suggestions and shortcuts
Made-with: Cursor
* chore: ignore and untrack work/backlog for promotion to main
Made-with: Cursor
* fix(ci): SDD sanity checks skip gitignored work/backlog
Made-with: Cursor
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Test plan
Sprint context
task/m11-storefront-cms-synergystagingMade with Cursor
Summary by CodeRabbit
New Features
Improvements
Other