Party Planner
Party Planner lets a customer create an event (a wedding, a bachelorette, a prom), invite the people attending, and collectively decide what everyone will wear.
This page has two halves. How It Works is written for merchandising, product, and WebOps and assumes no knowledge of the codebase. Technical Reference is for engineers working on the feature.
How It Works
What an event isβ
An event has a name and a date, a color palette, a roster of members, and a dressing room β the
shared list of products the party is considering. Members reach it at /party/<event id>.
The <event id> in that URL is a random public id, not a customer or order number. Anyone can start
an event through the onboarding survey at /party/onboarding, but it is only saved once they sign
in: a logged-out customer's answers are held locally and submitted on their next signed-in visit.
Save To Eventβ
A customer who belongs to at least one event can add products to that event's dressing room straight from the favorites heart, anywhere on the site.
What the customer sees:
- They click the heart on a product card or a product page.
- The product is saved to their favorites, exactly as it always has been.
- A modal opens showing the product, a "Saved To Favorites" confirmation, and a dropdown of their events.
- They pick an event and click Add To Event. The button reads Addingβ¦, then Added.
Everyone adds for themselves. If someone else has already added a look, it still shows Add To Event for you, and adding it puts your name on it too rather than taking theirs off. That is how the party can see how many people picked the same look: the Dressing Room shows each person's initials above it. So Added means you added it, not that it is in the event.
For a customer with no events β logged out, no event created, or the feature turned off β the heart behaves exactly as it always has. Nothing changes for them.
Things worth knowingβ
The heart does not show event membership. It fills in only when a product is in the customer's favorites. A product sitting in an event's dressing room does not fill the heart. This was a deliberate scoping decision.
Products cannot be removed from an event yet. Once a product is added it stays there. Removal is planned as part of the Dressing Room work (SITE-2087).
The dropdown defaults to the soonest upcoming event. A customer in several events who does not check the dropdown could add a product to the wrong one. An event switcher on product pages is planned to address this.
Adding twice is harmless. Adding a product that is already in the event does nothing, rather than creating a duplicate.
The dressing room is shared. Anything a member adds is visible to the whole party.
Turning it on and testing itβ
The whole feature sits behind the h2_party-planner feature gate. With the gate off, every
Party Planner route redirects to the 404 page and the favorites heart reverts to its normal behavior.
There is no separate switch for Save To Event. Note it is a redirect, not a 404 status, so these show
up in logs as 302s rather than 404s.
One exception: Builder preview URLs bypass the gate, so content authors can keep editing the
onboarding form while the feature is off. This mirrors how the no-index gate treats preview, and it
applies in every environment. If a Party Planner page loads with the gate off, check for
builder.preview / builder.frameEditing on the URL before assuming the gate is broken.
Gates can be toggled per-environment on the /gates page.
To test the flow end to end:
- Turn on
h2_party-planner. - Sign in, then create an event at
/party/onboarding. - Go to any collection or product page and click a heart.
- Confirm the modal opens, pick the event, and click Add To Event.
- Confirm the button settles on Added.
h2_party-planner-debug-mode covers the parts that would otherwise need Shopify OAuth: with it on,
the event routes grant access without authenticating at all, and the debug tool can view the event as
any member on the roster. It is a deliberate full bypass, so it helps from step 3 onward. Creating an
event still derives the owner from the signed-in customer.
To check that two people can add the same look, use the member dropdown in the debug tool: pick a person, add a look from a product page, then switch to someone else and check that the same product still offers Add To Event. Two things trip people up here:
- You need to be on the event's roster yourself. The list of events in the modal comes from who you are signed in as, not from whoever you picked in the dropdown. Join the event with your own account using the invite link on the Manage Party tab, or the heart will not open the modal.
- If you have not picked a person yet, the add goes to the event's owner. Choose someone in the dropdown before you add anything. That choice only applies to the event you made it on, so if you have more than one event, check the modal is set to the same one.
Events live in a separate backend service. There is seeded Party Planner data in production, so the flow can be exercised there as well as on local and staging.
When something goes wrongβ
Failures surface to the customer as "Something went wrong. Please try again." under the event dropdown, and are reported to Sentry with the event id and product id attached.
The most likely causes are the backend service being unreachable, or the customer having been removed from the event since the page loaded.
Technical Reference
Data architectureβ
The storefront-backend GraphQL APIβ
Every event read and write goes through a single GraphQL endpoint. Documents live in
app/graphql/storefront-backend/Events.ts; storefrontBackendGraphql()
(app/lib/storefrontBackend/graphql.server.ts) POSTs them with an X-API-KEY shared secret.
Two properties of that client matter before writing a new caller:
- It does not throw on GraphQL errors. It returns the raw
{ data, errors }, so every helper inapp/lib/party/event.server.tscheckserrors?.lengthitself and reports throughcaptureIssue. - Authorization is caller-supplied. Mutation inputs carry a
requestorGidand the resolvers read no session. A storefront caller must derive the GID server-side withgetCustomerGidand run its own permission check before calling.
Codegen is a graphql-config project in .graphqlrc.ts, with its schema URL pinned to
STOREFRONT_BACKEND_BASE_URL (staging), so npm run codegen needs that env var set. The pin is there
because storefront-backend is disabled in production, which leaves codegen no schema to introspect
there. Treat it as required until that service is enabled in production; .graphqlrc.ts names the
replacement to switch to at that point (API_GATEWAY_BASE_URL plus PRIVATE_API_GATEWAY_KEY).
Because app/types/storefront-backend/graphql.ts is generated from the live schema, it documents the
whole backend API, not just the parts the storefront uses. Check it before assuming a capability
is missing; the gap is usually the client document, not the API.
Cachingβ
Writes always pass CacheStrategy.none. Hydrogen's WithCache has no invalidate, so a write cannot
clear a cached read β any surface that must see its own write has to read uncached too.
Event summariesβ
The root loader returns a deferred eventSummariesPromise from loadEventSummaries(), which
short-circuits to Promise.resolve([]) when there is no customer GID or when h2_party-planner is
off. The gate and auth check therefore live in one place rather than at each consumer.
PageLayout mounts EventSummariesProvider. Its Suspense fallback still renders a provider with
{ events: [] }, so useEventSummaries() never throws while the promise is pending. Consumers must
treat "no events" and "not resolved yet" as the same observable state, and the hook deliberately
does not throw when no provider is mounted, because FavoriteButton consumes it on every product
surface including stories and specs.
EventSummary is a light shape β publicId, name, date, orderByDate, eventType, role,
weddingRole. No products, no members, no palette. Anything richer needs getEvent, which is
per-event and server-only.
Two behaviors of getEventSummaries on the backend shape what the storefront can assume:
- It filters
WHERE date >= CURRENT_DATE, so past events never appear. - It returns rows already sorted ascending by date, so
events[0]is the soonest upcoming event.
There is deliberately no "current event" concept. An earlier design tracked one in the session so
the heart could reflect a dressing room; once the heart was scoped back to favorites-only, the sole
remaining consumer was the modal's dropdown default, which events[0] already answers.
Manage Events does not use the providerβ
Root's shouldRevalidate returns true for any non-GET submission but false for GET navigations,
so eventSummariesPromise re-resolves after a write yet never on a plain client-side nav. Event
creation posts through a fetcher, so the heart and the Save To Event modal do pick a new event up.
But freshness then depends on what happens to have submitted since the last document load, which is
not something an individual page can reason about.
account.events.tsx therefore calls loadEventSummaries in its own loader and renders from
useLoaderData, so the list is right on every navigation to the page (SITE-2067). It guards
getCustomerGid behind isLoggedIn: the parent account.tsx redirects logged-out visitors, but
loaders run in parallel, so the child cannot lean on that.
That loader does not feed the provider. Loader data is scoped per route, so
useEventSummaries() is untouched by this page and the two refresh independently. They diverge only
when the event list changes with no submission in this tab β an event created in a second tab, say β
in which case Manage Events is correct and the provider is stale until the next document load or
submission.
Save To Event implementationβ
The heart forksβ
FavoriteButton picks one of two paths from useEventSummaries().events.length:
| Customer | Clicking the heart |
|---|---|
| No events | The original toggle: favorite, or unfavorite |
| One or more events | Favorites the product, then opens the Save To Event modal |
The second path never unfavorites β removal moves into the modal β so a second click just reopens
it. Filled state is isFavorited(productId) and nothing else.
Scoping is free: loadEventSummaries returns [] when the gate is off, so the no-event path is the
fallback. There is no second gate to keep in sync.
The modalβ
app/components/Party/SaveToEventModal.tsx, opened through the global ModalProvider with
ModalConfigs.SAVE_TO_EVENT. It reuses WaitlistCard's two-column product layout.
It owns all of its own state. useProductDisplayData prefers the favorites enrichedProductMap and
falls back to /api/products/:id β that fallback is why FavoriteButton needs no new props at any
of its four call sites. useSelectedEventProductGids fetches the products the customer
themselves added to the selected event and exposes a setProductGids the CTA calls with the
action's re-read after a save; nothing preloads it, so the CTA stays disabled until it lands rather
than briefly offering an add the customer already made.
The modal is add-only. Its CTA runs Add To Event β Addingβ¦ β Added; Added is a role="status"
element rather than a disabled Button, both because the disabled styling washes the label out and
because a completed save is a confirmation, not an unavailable action. Removal moves to the Dressing
Room (SITE-2087).
The API routeβ
app/routes/api.party.event-products.ts:
| Handler | Request | Job |
|---|---|---|
loader | GET ?eventId= | Reads the product GIDs the requestor added to one event |
action | POST | { intent, eventId, productGid } |
Both run the shared authorizeEventApiAccess (app/lib/party/access.server.ts), the same gate
api.party.assign-look and api.party.edit-event use: resolve the requestor (server-derived, never
client-supplied) β uncached getEvent β getPermissionLevel(...) !== 'NONE'. Members and admins
alike may curate a dressing room, so membership is the only question.
Using the shared gate is also what makes per-member attribution testable: it resolves the impersonated member in debug mode, so the debug tool's dropdown changes who an add belongs to. A route that rolls its own gate silently loses that.
Two deliberate choices:
- It returns a status rather than throwing a redirect, unlike
requireEventAccess. A fetcher following a 302 to the login page would receive an HTML body it cannot parse. - A non-member gets the same
404a missing event gives, so the route cannot be used to probe which events exist.
After a write it re-reads the list uncached (read-your-own-writes) so the response carries the mutation it just made.
Add and remove report success differentlyβ
Both mutations return EventProduct | null, but null means opposite things. This is the sharpest
edge in the feature.
| Mutation | What null means | How to check success |
|---|---|---|
addProductToEvent | The event does not exist | The payload. Non-null is success. |
removeProductFromEvent | Usually success | The absence of GraphQL errors. |
Removal is per member: it deletes only the requestor's add row, and the backend's toEventProduct
returns null once no adds remain. So the most common removal β you were the sole adder β resolves to
null. Treating that as failure would roll the UI back and fire Sentry over a write that succeeded.
The errors-only check is safe because not-a-member throws FORBIDDEN, which lands in errors
rather than returning null. removeProductFromEvent in event.server.ts returns a plain boolean
for exactly this reason.
Two consequences for the UI: a product another member also added stays in the dressing room after you
remove yours. And getEvent drops zero-add products at read time, so a product with no adds left
never comes back in a re-read.
Adding is idempotent β onConflictDoNothing on the product row, and a timestamp refresh rather than a
duplicate add row.
Event accessβ
Route-level gating for /party/:eventId and the palette editor β permission levels, the debug-mode
bypass, and the order the guards run in β is documented separately in
Party Planner Event Access.
Analyticsβ
Favoriting from the modal publishes the standard FAVORITES_ADD / FAVORITES_REMOVE events, so it
lands in the same GTM funnel as favoriting from a product card.
Analytics for the party planner experience is yet to be added.