Skip to content

M11 split: storefront routes and core libs#20

Merged
eskobar95 merged 8 commits into
stagingfrom
task/m11-split-storefront-routes-lib-core
Mar 30, 2026
Merged

M11 split: storefront routes and core libs#20
eskobar95 merged 8 commits into
stagingfrom
task/m11-split-storefront-routes-lib-core

Conversation

@eskobar95
Copy link
Copy Markdown
Owner

@eskobar95 eskobar95 commented Mar 29, 2026

Summary

  • Storefront app routes, API handlers, contexts, i18n, middleware, and core library integration changes.
  • Split from Sprint M11 mega-branch to keep this PR under CodeRabbit's file threshold and aligned with project architecture boundaries.
  • Ownership alignment: Medusa for commerce state, Payload for editorial content, Storefront for read/render UX.

Test plan

  • Run relevant lint/type/test commands for changed app scope
  • Smoke-test affected flows in local environment
  • Verify no regressions outside this PR scope

Sprint context

  • Sprint: M11
  • Source branch: task/m11-storefront-cms-synergy
  • Target: staging

Made with Cursor

Summary by CodeRabbit

  • New Features

    • Wishlist with account sync, persistent recent searches, newsletter signup, cookie-consent & analytics controls; CMS-driven pages (homepage, blog, brands, footer); new account pages (profile, addresses, orders, subscriptions); enhanced order confirmation with PDFs/tracking.
  • Improvements

    • Real search results (products + articles), free-shipping status, mobile cart drawer, add-to-cart modal, redesigned cart & checkout (totals, VAT, pickup/pakkeshop flow), preserved return URLs and improved accessibility/localized copy.
  • Other

    • New error pages and global UI/style refinements.

Moves storefront route handlers, app routes, state contexts, i18n, and core lib updates into a focused PR under CodeRabbit limits.

Made-with: Cursor
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

Warning

Rate limit exceeded

@eskoubar95 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 11 minutes and 23 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 23ee9261-6f78-4377-90e1-d1d74eb59d65

📥 Commits

Reviewing files that changed from the base of the PR and between 3694854 and 07ebeb2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (13)
  • apps/storefront/src/app/[locale]/[...path]/page.tsx
  • apps/storefront/src/app/[locale]/page.tsx
  • apps/storefront/src/components/AccountLayout.tsx
  • apps/storefront/src/components/ProductCard.tsx
  • apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx
  • apps/storefront/src/components/checkout/hooks/useCheckoutPickup.ts
  • apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts
  • apps/storefront/src/components/product-card-a11y.ts
  • apps/storefront/src/components/sections/ContentBlockSection.tsx
  • apps/storefront/src/components/sections/PromotionSlider.tsx
  • apps/storefront/src/i18n/dictionaries.ts
  • apps/storefront/src/i18n/dictionaries/da.json
  • apps/storefront/src/i18n/dictionaries/en.json
📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Config & Packaging
apps/storefront/next.config.ts, apps/storefront/package.json, apps/storefront/tsconfig.json
Turbopack root added; Medusa image remotePattern helper; new scripts and dependencies (playwright, sharp, UI libs); TS baseUrl set.
App Shell & Routing
apps/storefront/src/app/... (layout/page/error/global-error/globals.css, catch-all route ...[...path]/page.tsx)
Layout now fetches dict/navigation/footer, nests providers (cookie/auth/cart/modal), adds global and locale error boundaries, large CSS/token changes, plus CMS-driven homepage and a Payload catch-all page route.
Account & Auth Pages
apps/storefront/src/app/[locale]/account/**, .../login/**, .../register/**, .../auth/google/callback/page.tsx
Many new account pages (addresses, profile, wishlist, subscriptions, orders redirect); login/register accept/propagate returnUrl and show status messages; Google callback preserves returnUrl and adds redirect phases.
Cart / Checkout / Orders
apps/storefront/src/app/[locale]/{cart,checkout,order-confirmation}/**, src/components/cart/**, src/components/checkout/**, src/contexts/CheckoutCartContext.tsx
Server-side cart-data helpers, cart display/discount/tax logic, free-shipping config/status integrations, CheckoutCartProvider, many checkout steps/hooks/components, mobile cart drawer, client order confirmation hydration.
APIs
apps/storefront/src/app/api/* (cart, free-shipping, line-item, products, search, newsletter, geocode)
New Next.js route handlers for cart GET, line-item POST, free-shipping proxy, products by handles, search aggregation, newsletter subscribe (Plunk), and geocoding (Nominatim).
Payload CMS & Medusa libs
src/lib/payload-*.ts, src/lib/medusa-products.ts, src/lib/medusa.ts, src/lib/payload-*
New Payload homepage/navigation/footer/articles/media helpers; enriched Medusa product fetchers with region/pricing, caching, and backend URL helper.
Server helpers & cache
src/lib/* (cart-data, orders, account-summary, server-cache, free-shipping-config/status, shipping-config, subscription-config)
New server helpers: cart id/cart read, order fetching, account summary, in-process server cache, free-shipping config/status fetchers, shipping threshold and subscription constant.
Cart display, inventory & errors
src/lib/cart-display.ts, src/lib/product-inventory.ts, src/lib/cart-errors.ts
Price/total normalization, variant inventory/stock logic and caps, and inventory-aware user-friendly cart error mapping.
Homepage helpers & Lexical
src/lib/resolve-homepage-data.ts, homepage-primary-hero.ts, payload-media-url.ts, lexical-to-html.ts
Resolve CMS homepage sections, compute above-fold hero image, resolve Payload media URLs, and convert sanitized Lexical rich text to HTML.
Contexts & Hooks
src/contexts/*, src/hooks/*
New contexts: Cart, Wishlist, CheckoutCart, AddToCartModal; Auth.signOut extended; hooks: useMediaQuery, useFreeShippingStatus, many checkout/pickup/payment helpers.
Components — Cookie Consent System
src/components/cookie-consent/** (provider, script-manager, tracker, utils, UI, hooks)
Full cookie-consent framework: provider, settings/banner/trigger, consent-script API, script registry/loader/unloader, tracker with retry, utilities and hooks for consent-aware script loading.
Components — UI, Account, Cart, Checkout, Home Sections, Product
src/components/** (huge set)
Many new/refactored components: account clients/panels, checkout step components and hooks, cart dropdown/mobile drawer/items/add-to-cart modal, product purchase/wishlist, newsletter form, search UI, blog components, home sections (hero, promo bars, slider, carousels), analytics providers, etc.; multiple public prop/type changes.
i18n dictionaries
src/i18n/dictionaries.ts, src/i18n/dictionaries/{da,en}.json
getDictionary wrapped with React cache; Dictionary interface greatly expanded; DA/EN JSON files extended with search, wishlist, checkout, cart, account, orderConfirmation, subscription, blog, and many new keys.
Pickup & Shipping
src/lib/pickup-points.ts, src/components/checkout/hooks/*, src/components/checkout/shipping-option-carrier.ts
Pickup points extended with coords and distance enrichment, geocode integration, checkout pickup hooks, shipping options hook and carrier-selection utilities.
Formatting & Auth utils
src/lib/format.ts, src/lib/auth-utils.ts, src/lib/order-utils.ts
Locale-aware price/date formatting, safe return URL helpers, order normalization and session-storage-friendly conversions.
Styling & Tooling
apps/storefront/src/app/globals.css, .stylelintrc.json
Tailwind/shadcn imports, CSS tokens and custom variant, new utility classes/animations, reduced root font-size, and stylelint SCSS at-rule allowances.

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
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through routes and sprites of code,

I cached a page and fetched a load,
I stitched the cart, the hero, and the feed,
With cookies, consent, and lots of speed,
A happy rabbit shipped the store's new mode.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task/m11-split-storefront-routes-lib-core

@eskobar95
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@eskobar95
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🔴 Critical

CI blocker: lockfile is out of sync with package.json.

The pipeline failure (ERR_PNPM_OUTDATED_LOCKFILE) is rooted in this dependency/scripts change set. Until pnpm-lock.yaml is 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 | 🟠 Major

Keep 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-visible treatment 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 | 🟠 Major

Don’t log raw upstream error bodies containing potential PII.

Line [72] and Line [98] log errText directly. 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 | 🟠 Major

Add timeouts to outbound Plunk requests.

Line [57] and Line [83] use fetch without 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 | 🟠 Major

Avoid 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 | 🟠 Major

Constrain heading tags to an allowlist.

Line 45 interpolates node.tag directly into HTML tag names. Restrict to h1h6 to 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 | 🟠 Major

Current 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 when cache.size remains 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 | 🟠 Major

Wrap case "text" declaration in a block to satisfy Biome.

The let out declaration in this switch clause triggers lint/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 | 🟠 Major

Add 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 | 🟠 Major

Add defensive guards to Intl formatting functions against invalid locale/currency/date inputs.

Intl.NumberFormat throws RangeError on invalid ISO 4217 currency codes or invalid BCP 47 locale strings. Date.toLocaleDateString produces "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 | 🟠 Major

Don'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 the Cookie header 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 | 🟠 Major

The shipping-unit heuristic will misprice real orders.

Amount-based guessing fails both ways: 995 øre stays 995, while a legitimate 1200 DKK charge becomes 12. 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 | 🟠 Major

Don't derive product availability from only the first variant.

firstVariant drives inStock, lowStock, and variantId here. 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 a variantId when 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 | 🟠 Major

Fetch variants.id anywhere you map to Product.

mapMedusaToProduct() now emits variantId, but these field lists never request variants.id. Category/search/related/random/bought-together results will therefore map to variantId: undefined even 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 | 🟠 Major

Validate CMS-provided external URLs.

resolveLinkHref() currently returns any Payload url verbatim. A javascript: 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 | 🟠 Major

Cap handles before dispatching to Medusa.

This endpoint is public, and fetchProductsByHandles() fans out one upstream request per handle. A large handles query 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 | 🟠 Major

Drop invalid CMS links instead of routing them to home or #.

page is explicitly allowed to be a numeric relation id here. In that case getPathFromPage() returns the same empty-string sentinel as the real home page, so resolveHref() sends the item to /${locale}; the other fallback becomes "#". Both cases make broken CMS data look like a valid nav item. Return null for 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 | 🟠 Major

The amount heuristic misclassifies 10,000+ major-unit values.

Anything >= 10_000 returns early, so SESSION_MAJOR_MAX_EXCLUSIVE never actually influences the result. A session value of 10000 stored in major units will later come back as 100 after sessionAmountToMajor(). 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-bars blocks never enter the typed section union.

PromoBarsBlock is declared here but omitted from HomepageSection, 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 | 🟠 Major

Persist 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 | 🟠 Major

These camelCase fallbacks are currently no-ops.

Both expressions read the snake_case property twice, so currencyCode and shippingAddress payloads 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 | 🟠 Major

Skip both caches when draft is enabled.

When draft is true, requests are cached via both the 60s in-memory cache and Next.js revalidate: 60, causing preview edits to appear stale for up to a minute. Draft reads should bypass getCached()/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 | 🟠 Major

Time-box the Payload lookup.

fetchArticles() fails open once fetch rejects, but it never times out. Because Line 42 uses Promise.all, a hung CMS request can stall the whole /api/search response 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 | 🟠 Major

Return a generic body for unexpected 500s.

This currently echoes err.message for 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 | 🟠 Major

Validate quantity before normalizing it.

The cast on Line 14 does not validate runtime JSON. A payload like { "quantity": "abc" } makes qty become NaN, skips the cap check, and still reaches updateLineItem instead 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 | 🟠 Major

Don'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 | 🟠 Major

Don't key the wishlist hydration guard only on customer.id.

If auth state exposes customer.id before customer.metadata, Line 42 marks the customer as loaded on the first pass and later metadata updates for the same user are skipped. That can prevent wishlist_handles from 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 | 🟠 Major

Persist the sanitized return URL for the Google flow.

The email path already uses getSafeReturnUrl, but Lines 83-86 store the raw returnUrl in sessionStorage. 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 | 🟠 Major

Don't use aggregate discount_total as the subscription readiness signal.

This cart now supports non-subscription discounts too. If a coupon is already applied, Line 195 becomes true before 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 | 🟠 Major

Reject invalid quantities instead of coercing them.

Math.max(1, Math.floor(Number(quantity))) still lets NaN through (JSON.stringify turns it into null) 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 | 🟠 Major

Keep 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|true is present. generateMetadata() ignores those cases, so preview URLs can still emit published metadata with robots.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 | 🟠 Major

Keep loading true 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 | 🟠 Major

Prevent protocol-based XSS injection in URLs.

The escapeHtml() function in lexicalToHtml escapes HTML entities (&, <, >, "), preventing direct HTML injection. However, it does not prevent protocol-based attacks: javascript:alert(1) and data: 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-parse or 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

📥 Commits

Reviewing files that changed from the base of the PR and between c84e24e and 28a68b4.

📒 Files selected for processing (89)
  • apps/storefront/next.config.ts
  • apps/storefront/package.json
  • apps/storefront/src/app/[locale]/[...path]/page.tsx
  • apps/storefront/src/app/[locale]/account/addresses/page.tsx
  • apps/storefront/src/app/[locale]/account/layout.tsx
  • apps/storefront/src/app/[locale]/account/orders/[id]/page.tsx
  • apps/storefront/src/app/[locale]/account/orders/page.tsx
  • apps/storefront/src/app/[locale]/account/page.tsx
  • apps/storefront/src/app/[locale]/account/profile/page.tsx
  • apps/storefront/src/app/[locale]/account/subscriptions/[id]/page.tsx
  • apps/storefront/src/app/[locale]/account/subscriptions/page.tsx
  • apps/storefront/src/app/[locale]/auth/google/callback/page.tsx
  • apps/storefront/src/app/[locale]/blog/[slug]/page.tsx
  • apps/storefront/src/app/[locale]/blog/page.tsx
  • apps/storefront/src/app/[locale]/brands/[handle]/page.tsx
  • apps/storefront/src/app/[locale]/cart/page.tsx
  • apps/storefront/src/app/[locale]/categories/[handle]/page.tsx
  • apps/storefront/src/app/[locale]/checkout/page.tsx
  • apps/storefront/src/app/[locale]/error.tsx
  • apps/storefront/src/app/[locale]/layout.tsx
  • apps/storefront/src/app/[locale]/login/AuthRequiredBanner.tsx
  • apps/storefront/src/app/[locale]/login/LoginForm.tsx
  • apps/storefront/src/app/[locale]/login/page.tsx
  • apps/storefront/src/app/[locale]/order-confirmation/OrderConfirmationComplete.tsx
  • apps/storefront/src/app/[locale]/order-confirmation/OrderConfirmationContent.tsx
  • apps/storefront/src/app/[locale]/order-confirmation/[orderId]/page.tsx
  • apps/storefront/src/app/[locale]/page.tsx
  • apps/storefront/src/app/[locale]/policies/cookies/page.tsx
  • apps/storefront/src/app/[locale]/products/[handle]/page.tsx
  • apps/storefront/src/app/[locale]/register/RegisterForm.tsx
  • apps/storefront/src/app/[locale]/register/page.tsx
  • apps/storefront/src/app/[locale]/search/page.tsx
  • apps/storefront/src/app/[locale]/wishlist/WishlistPageContent.tsx
  • apps/storefront/src/app/[locale]/wishlist/page.tsx
  • apps/storefront/src/app/api/cart/free-shipping/route.ts
  • apps/storefront/src/app/api/cart/line-item/route.ts
  • apps/storefront/src/app/api/cart/route.ts
  • apps/storefront/src/app/api/geocode/route.ts
  • apps/storefront/src/app/api/newsletter/route.ts
  • apps/storefront/src/app/api/product-reviews/route.ts
  • apps/storefront/src/app/api/products/route.ts
  • apps/storefront/src/app/api/search/route.ts
  • apps/storefront/src/app/global-error.tsx
  • apps/storefront/src/app/globals.css
  • apps/storefront/src/contexts/AddToCartModalContext.tsx
  • apps/storefront/src/contexts/AuthContext.tsx
  • apps/storefront/src/contexts/CartContext.tsx
  • apps/storefront/src/contexts/CheckoutCartContext.tsx
  • apps/storefront/src/contexts/WishlistContext.tsx
  • apps/storefront/src/hooks/use-media-query.ts
  • apps/storefront/src/hooks/useFreeShippingStatus.ts
  • apps/storefront/src/i18n/dictionaries.ts
  • apps/storefront/src/i18n/dictionaries/da.json
  • apps/storefront/src/i18n/dictionaries/en.json
  • apps/storefront/src/lib/account-status-labels.ts
  • apps/storefront/src/lib/account-summary.ts
  • apps/storefront/src/lib/auth-utils.ts
  • apps/storefront/src/lib/cart-data.ts
  • apps/storefront/src/lib/cart-display.ts
  • apps/storefront/src/lib/cart-errors.ts
  • apps/storefront/src/lib/cart.ts
  • apps/storefront/src/lib/fetch-client-cart.ts
  • apps/storefront/src/lib/format.ts
  • apps/storefront/src/lib/free-shipping-config.server.ts
  • apps/storefront/src/lib/free-shipping-status.ts
  • apps/storefront/src/lib/home-mock.ts
  • apps/storefront/src/lib/homepage-primary-hero.ts
  • apps/storefront/src/lib/lexical-to-html.ts
  • apps/storefront/src/lib/medusa-product-reviews.ts
  • apps/storefront/src/lib/medusa-products.ts
  • apps/storefront/src/lib/medusa.ts
  • apps/storefront/src/lib/order-utils.ts
  • apps/storefront/src/lib/orders.ts
  • apps/storefront/src/lib/payload-articles.ts
  • apps/storefront/src/lib/payload-footer.ts
  • apps/storefront/src/lib/payload-homepage.ts
  • apps/storefront/src/lib/payload-media-url.ts
  • apps/storefront/src/lib/payload-navigation.ts
  • apps/storefront/src/lib/pickup-points.ts
  • apps/storefront/src/lib/product-inventory.ts
  • apps/storefront/src/lib/resolve-homepage-data.ts
  • apps/storefront/src/lib/review-display-name.ts
  • apps/storefront/src/lib/server-cache.ts
  • apps/storefront/src/lib/shell-path.ts
  • apps/storefront/src/lib/shipping-config.ts
  • apps/storefront/src/lib/subscription-config.ts
  • apps/storefront/src/lib/utils.ts
  • apps/storefront/src/middleware.ts
  • apps/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)

Comment thread apps/storefront/src/app/api/cart/free-shipping/route.ts Outdated
Comment thread apps/storefront/src/contexts/AuthContext.tsx Outdated
Comment thread apps/storefront/src/lib/free-shipping-config.server.ts Outdated
Comment thread apps/storefront/src/lib/lexical-to-html.ts Outdated
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
@eskobar95
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
apps/storefront/src/app/api/cart/free-shipping/route.ts (1)

77-81: Keep !cartId responses 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 !cartId branch 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

📥 Commits

Reviewing files that changed from the base of the PR and between 28a68b4 and 98e0d55.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • apps/storefront/src/app/api/cart/free-shipping/route.ts
  • apps/storefront/src/app/globals.css
  • apps/storefront/src/contexts/AuthContext.tsx
  • apps/storefront/src/lib/free-shipping-config.server.ts
  • apps/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-motion fallbacks for both marquee and drawer animations.

Comment thread apps/storefront/src/app/globals.css
Comment thread apps/storefront/src/app/globals.css
Comment thread apps/storefront/src/app/globals.css
Comment thread apps/storefront/src/app/globals.css
- 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
@eskobar95
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@eskobar95 eskobar95 marked this pull request as ready for review March 30, 2026 19:23
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 30, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Missing modal accessibility attributes and focus management.

The mobile bottom sheet functions as a modal dialog but lacks essential ARIA attributes and focus management:

  1. Missing ARIA attributes: The sheet container should have role="dialog" and aria-modal="true" to announce it properly to screen readers.
  2. No focus trap: Keyboard users can tab out of the open menu to elements behind the backdrop.
  3. 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 | 🟠 Major

Add ARIA live region attributes for success/error feedback.

The feedback status element is currently not announced by screen readers. Add role, aria-live, and aria-atomic attributes 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 | 🟠 Major

Configure remotePatterns for Medusa image URLs before using next/image.

The suggestion to use next/image is sound—the parent container has the required position: relative and fixed dimensions for the fill prop. However, the current remotePatterns in next.config.js only allows Supabase URLs. Since item.thumbnail comes from the Medusa backend API, you must first add the Medusa domain to remotePatterns or the images will fail to load when using next/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/image with the fill prop.

🤖 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 | 🟠 Major

Ignore 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 | 🟠 Major

Keep 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. Add aria-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 | 🟠 Major

Don't fall back to street text for zipcode.

When address1 is present but postalCode is empty, combined.slice(0, 4) can send values like "Main" as zipcode, 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 | 🟠 Major

Make onFormDataChange required to avoid silent non-editable checkout fields.

Line 11 defines onFormDataChange as 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 | 🟠 Major

Reset 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 stale selectedShippingOptionId from 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 | 🟠 Major

Do 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 | 🟠 Major

Handle 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 | 🟠 Major

Don't auto-select shipping_options[0] at payment time.

If selectedShippingOptionId is missing or stale, this silently picks whatever listCartOptions() 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 | 🟠 Major

Include contact fields in the session reuse signature.

The reuse guard ignores formData.email and formData.phone, but medusa.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 | 🟠 Major

Async prefill can clobber in-progress edits.

listAddress() resolves after the form is already interactive, then setFormData(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-width is declared as a valid layout but is never rendered.

Line 15 accepts "full-width", but Line 115 returns null for 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 | 🟠 Major

Avoid 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 | 🟠 Major

Guard missing article.slug before 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 | 🟠 Major

Re-consent path does not re-enable PostHog capture.

After opt_out_capturing(), a later consent grant does not call opt_in_capturing() again because it is currently inside the first-init guard. The effect will skip the entire if block when initialized.current is 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 | 🟠 Major

Normalize subscription_cycle before deriving the subscription UI.

Metadata values frequently round-trip as strings, and the current numeric-only branch collapses "4" to 0. 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 | 🟠 Major

Bulk accept/reject is derived from module defaults, not the rendered config.

The dialog renders config.categories ?? defaultCategories, but both bulk handlers seed localCategories from helpers that never see that config. If a deployment overrides required or default flags, this dialog can stage a consent map that disagrees with the categories the user just reviewed. Please derive those maps from categories (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 | 🟠 Major

Double execution of revoke handler confirmed.

The onRevoke callback is registered in two separate paths and both execute during script unload:

  1. The config's onRevoke (line 85-88) is stored in the script registry and called at line 142 of script-manager.ts
  2. The same callback is separately registered via registerCleanup() (line 110-114) and called again at lines 145-148 of script-manager.ts

This causes the user-provided onRevoke prop to execute twice when consent is revoked. Remove the registerCleanup registration on line 110-114, as the config's onRevoke handler 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 | 🟠 Major

Normalize 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 | 🟠 Major

Clamp 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 | 🟠 Major

Linked slides need an accessible name.

When href is present, the link contains only an image with alt="", 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 | 🟠 Major

Clear stale load errors before exposing the hook state again.

Once loadScript() rejects, error is never reset. That leaves consumers stuck with a stale error even after a later successful load(), 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 | 🟠 Major

Don't double-prefix localized slide URLs.

resolveHref() prepends /${locale} for every non-HTTP URL, so CMS values like /da/foo or da/foo become /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 | 🟠 Major

Don'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 | 🟠 Major

Add options to the effect dependency array or memoize it.

This effect only depends on id, but captures and registers src, content, category, strategy, and attributes from options. If any of these change while id remains the same, the old registration persists and the hook loads the stale script definition instead of the updated one. The onRevoke callback is kept in sync via useLayoutEffect, but the script definition itself is not. Either add the relevant option properties to the dependency array or memoize the options object 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 | 🟠 Major

Make loadScript() idempotent while a load is in flight.

managed.loaded stays false until onload/the timeout runs, so a second loadScript(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 | 🟠 Major

Honor explicit googleConsentMode config when rendering.

effectiveGoogleConsentMode can already be enabled by config at Lines 95-96, but the render path still requires hasGoogleScripts. 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

expirationDays never expires the saved consent.

expiresAt is computed here, but it only goes to trackConsent; 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 | 🟠 Major

Don'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 | 🟠 Major

Guard all storage operations with try/catch blocks.

The typeof window check does not protect against SecurityError thrown by localStorage.getItem(), setItem(), or removeItem() 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 in try/catch and 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 | 🟠 Major

Fix cookie deletion to handle parent-domain cookies.

These helpers cannot delete cookies set with parent-domain attributes (e.g., Domain=.example.com). The code attempts domain=www.example.com and domain=.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 with Domain=.example.com; for api.example.com, try Domain=.example.com, etc.

This applies to all cleanup helpers: googleAnalytics (lines 266–277), facebookPixel (lines 289–300), and clearCookiesByPrefix (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
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

🧹 Nitpick comments (2)
apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx (1)

34-39: Prefer a concrete cart guard over repeated as StoreCart assertions.

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 a loading hint, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3ebb198 and 3694854.

📒 Files selected for processing (25)
  • .stylelintrc.json
  • apps/storefront/next.config.ts
  • apps/storefront/src/components/AccountLayout.tsx
  • apps/storefront/src/components/CheckoutSteps.tsx
  • apps/storefront/src/components/CheckoutWithStripe.tsx
  • apps/storefront/src/components/ProductCard.tsx
  • apps/storefront/src/components/account/PickupPointManager.tsx
  • apps/storefront/src/components/account/ProfileForm.tsx
  • apps/storefront/src/components/analytics/PostHogProvider.tsx
  • apps/storefront/src/components/blog/ArticleCard.tsx
  • apps/storefront/src/components/cart/CartItemRow.tsx
  • apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx
  • apps/storefront/src/components/checkout/HeaderBackButton.tsx
  • apps/storefront/src/components/checkout/hooks/useCheckoutPickup.ts
  • apps/storefront/src/components/checkout/hooks/usePaymentSession.ts
  • apps/storefront/src/components/checkout/hooks/usePickupPointSheetSearch.ts
  • apps/storefront/src/components/checkout/hooks/useShippingOptions.ts
  • apps/storefront/src/components/checkout/steps/ContactForm.tsx
  • apps/storefront/src/components/sections/BlogCarouselSection.tsx
  • apps/storefront/src/components/sections/ContentBlockSection.tsx
  • apps/storefront/src/components/sections/PromotionSlider.tsx
  • apps/storefront/src/i18n/dictionaries.ts
  • apps/storefront/src/i18n/dictionaries/da.json
  • apps/storefront/src/i18n/dictionaries/en.json
  • openmemory.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_total overriding 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.aborted before 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_code is 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 useCheckoutPickup keeps this component focused on step transitions, and the onRegisterGoToStep bridge stays easy to follow.

Comment thread apps/storefront/src/components/AccountLayout.tsx
Comment thread apps/storefront/src/components/AccountLayout.tsx
Comment thread apps/storefront/src/components/AccountLayout.tsx
Comment thread apps/storefront/src/components/checkout/CheckoutOrderSummary.tsx Outdated
Comment thread apps/storefront/src/components/checkout/hooks/useCheckoutPickup.ts
Comment thread apps/storefront/src/components/sections/ContentBlockSection.tsx
Comment thread apps/storefront/src/components/sections/ContentBlockSection.tsx
Comment thread apps/storefront/src/components/sections/ContentBlockSection.tsx
Comment thread apps/storefront/src/components/sections/PromotionSlider.tsx
Comment thread apps/storefront/src/components/sections/PromotionSlider.tsx
- 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
@eskobar95
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 30, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@eskobar95 eskobar95 merged commit 80d1cdb into staging Mar 30, 2026
6 checks passed
eskobar95 added a commit that referenced this pull request Apr 15, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant