Skip to content

Make the Judge.me and Klaviyo surfaces integration-ready - #65

Merged
hta218 merged 7 commits into
mainfrom
feat/integration-ready-judgeme-klaviyo
Sep 15, 2026
Merged

hta218 merged 7 commits into
mainfrom
feat/integration-ready-judgeme-klaviyo

Conversation

@HoangKyPham

Copy link
Copy Markdown

Summary

Implements the integration surfaces from this ticket that have approved designs — reviews and newsletter signup — and puts both behind server-side tokens with fallback states for when those tokens are absent. Four scope items are deferred; see Deferred scope below.

Closes #53.

Scope covered

Reviews summary / stars / list. Judge.me was living inside the product route. It moves to app/routes/api/reviews.ts, with its own entry in app/routes.ts, so the vendor's request shapes, error handling and empty states sit in one file rather than being threaded through a route that also loads the product. review-summary.tsx and judgeme-stars-rating.tsx consume it.

Klaviyo / newsletter signup. The footer card and the popup now gate on whether Klaviyo is configured, so an unconfigured store does not offer a form that cannot work.

Subscription selector and analytics extension points needed no work: the selector already reads Shopify selling plan groups, and analytics already runs through Analytics.Provider plus GTM behind the public PUBLIC_GOOGLE_GTM_ID.

Technical requirements

No private token reaches the browser. JUDGEME_PRIVATE_API_TOKEN and KLAVIYO_PRIVATE_API_TOKEN are read from context.env inside routes only. What the client receives is a pair of booleans from app/utils/root.server.ts:

integrations: {
klaviyo: Boolean(env.KLAVIYO_PRIVATE_API_TOKEN),
judgeme: Boolean(env.JUDGEME_PRIVATE_API_TOKEN && env.PUBLIC_STORE_DOMAIN),
}

Surfaces gate on those, so no token value is needed client-side to decide what to render.

Public env vars stay client-safe. The only PUBLIC_ key involved is PUBLIC_GOOGLE_GTM_ID, a GTM container ID, which is public by design.

Vendor logic is isolated. Judge.me lives in app/routes/api/reviews.ts, Klaviyo in app/routes/($locale).api.klaviyo.ts. Neither vendor's shapes leak into components.

Unconfigured states fail closed rather than erroring. Reviews: GET returns 200 with an empty rating and an empty list, so the product page renders normally and the widget shows its empty state; POST returns 503 with a generic message. Newsletter: both surfaces are hidden. Provider error text is logged server-side only — the browser gets a generic message either way.

Studio still shows the gated surfaces. The gate is configured || isDesignMode, so a merchant can lay a section out before the token exists. Only the storefront hides it.

Also fixes

Splitting the early-return path out of the Klaviyo route left the provider's response body unread, and in workerd an unread stream keeps the request's I/O context alive, so the worker never completed — the request failed with "The script will never generate a response" after the profile had already been created. The previous code always parsed the response, which consumed the stream as a side effect. Both routes now cancel the body before returning early.

Deferred scope

Four items are not implemented, because the design work this ticket lists under Blocked by has not landed for them:

  • wishlist button and state
  • back-in-stock form placement
  • size guide integration point
  • loyalty points hint

Happy to pick these up once the designs are approved; the token boundary and the fail-closed pattern established here should carry over unchanged.

Testing

Both states were exercised for Klaviyo and Judge.me. Tokens absent: the newsletter surfaces are hidden and the review widget renders its empty state without offering the form. Tokens present: the newsletter creates a profile in Klaviyo, and a review submitted through the widget reached the Judge.me admin.

Confirmed neither token reaches the browser — absent from the production build output, from the rendered product page, and from the loader payload, which carries only the two booleans.

npm run build, npx tsc --noEmit and npm run biome all pass; the 17 biome warnings are pre-existing on main.

Setup notes

README.md gains a "Third-party integrations" section covering local versus Oxygen configuration and the public/private boundary, pointing at #56 for the full guide. .env.example gains comments marking which tokens are private.

Deploying this needs both tokens set on Oxygen for Preview and Production. Without them the surfaces stay in their unconfigured state — intended, but it will read as a regression, and one cosmetic artefact shows there: the review widget's empty layout reserves two columns while only one renders, so the stars sit left of centre. Left as is to keep this PR to the ticket's scope.

- move the reviews loader and action to routes/api/reviews.ts
- register /api/product/:productHandle/reviews as its own route
- return empty data instead of throwing when the token is unset
- expose only integrations.klaviyo / integrations.judgeme booleans from root
- hide the newsletter footer card, the popup and the review form when the
  matching token is unset
- return generic errors from the Klaviyo route instead of its payload
- document the private token setup in README and .env.example
@HoangKyPham
HoangKyPham requested a review from hta218 September 9, 2026 05:05
@HoangKyPham HoangKyPham added the enhancement New feature or request label Sep 9, 2026
@hta218

hta218 commented Sep 10, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@hta218

hta218 commented Sep 14, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@hta218 hta218 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work overall — the route split is clean, the res.body?.cancel() fix is a real one, and the README section is clear.

A few small changes before merging, see inline. #1 is the important one.

Comment thread app/routes/api/reviews.ts Outdated
Comment thread app/routes/api/reviews.ts Outdated
Comment thread app/routes/($locale).api.klaviyo.ts Outdated
Comment thread app/sections/main-product/judgeme-stars-rating.tsx
Comment thread app/routes.ts
The payload spread the whole form after shop_domain and platform, so a
crafted request could set either of them — the request still went out
signed with our private Judge.me token — and could smuggle any extra
field Judge.me accepts.

The six fields the form actually sends are now named, and the server's
own values come last so nothing can displace them. Replaying a request
carrying shop_domain=attacker.myshopify.com, platform=evil and
verified_buyer=true now sends our domain, "shopify", and eight keys
rather than nine.

formDataToObject returned `{}`, which does not allow destructuring, so
it now declares Record<string, FormDataEntryValue>. The review route is
its only caller.
`page` and `per_page` were parsed and handed to Judge.me unchecked, so
`page=abc` arrived as NaN and `per_page=100000` asked for the whole
review table in one request.

Both now clamp at the point they are read: anything that is not a whole
number above zero falls back, and per_page tops out at 50. The guard
further down that divided by `perPage > 0 ? perPage : 5` goes with it —
perPage cannot be zero any more, and leaving it would suggest otherwise.
The route forwarded Klaviyo's status to the browser, so a wrong or
expired private token answered the visitor with 401 — a status about our
call to Klaviyo, not about their request to us.

It now answers 503, matching the branch above that handles a missing
token: from the visitor's side, a token that is absent and a token that
is refused are the same failure. The 400 branch stays, since that one
really is about the email they typed.
Without a token the reviews API answers empty, so a store that has not
configured Judge.me still showed a review section inviting a write-up
through a form that cannot submit, and a stars rating stuck at zero.
The newsletter signup already hides itself in the same situation.

Both now follow that gate exactly — `(configured || isDesignMode)`,
reading the integrations.judgeme flag this PR already sends to the
client — so the surfaces disappear for shoppers while staying editable
in Studio.

Verified by running the preview build with JUDGEME_PRIVATE_API_TOKEN
removed: the review section stops rendering while the Klaviyo signup,
whose own token is untouched, still does.
Reviewer asked for a note so nobody reorders these two by accident. The
order turns out not to matter: swapping them and rebuilding leaves
/api/product/:handle, /api/product/:handle/reviews and the locale-
prefixed form all answering correctly, because a dynamic segment never
matches across a slash.

The constraint worth writing down is the one above it — these are two
distinct paths rather than one route with an optional `/reviews?`
segment, which is what makes them unambiguous in the first place.

@hta218 hta218 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All five points are addressed, thanks. Gating the whole reviews section, not just the stars, was the right call.

@hta218
hta218 merged commit 419d57a into main Sep 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Dev] Implement integration-ready blocks and app placeholders

2 participants