Skip to main content

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:

  1. They click the heart on a product card or a product page.
  2. The product is saved to their favorites, exactly as it always has been.
  3. A modal opens showing the product, a "Saved To Favorites" confirmation, and a dropdown of their events.
  4. 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:

  1. Turn on h2_party-planner.
  2. Sign in, then create an event at /party/onboarding.
  3. Go to any collection or product page and click a heart.
  4. Confirm the modal opens, pick the event, and click Add To Event.
  5. 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:

  1. It does not throw on GraphQL errors. It returns the raw { data, errors }, so every helper in app/lib/party/event.server.ts checks errors?.length itself and reports through captureIssue.
  2. Authorization is caller-supplied. Mutation inputs carry a requestorGid and the resolvers read no session. A storefront caller must derive the GID server-side with getCustomerGid and 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:

CustomerClicking the heart
No eventsThe original toggle: favorite, or unfavorite
One or more eventsFavorites 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:

HandlerRequestJob
loaderGET ?eventId=Reads the product GIDs the requestor added to one event
actionPOST{ 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 404 a 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.

MutationWhat null meansHow to check success
addProductToEventThe event does not existThe payload. Non-null is success.
removeProductFromEventUsually successThe 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.