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.
Add To Eventโ
A customer with an upcoming event can add products to that event's dressing room straight from product cards and from the product page gallery.
What the customer sees:
- Product cards show a + Add To Event pill in the bottom-left corner of the image. It appears on the home page, collection pages and product pages, including the cart drawer opened on one of those pages. These are the pages that show the event bar, so the customer can always see which event the product will go to. On a product page the pill also sits on the main gallery image, in the same corner, so the customer does not have to scroll to a card to add the product they are already looking at. It hides on video slides, matching the favorites heart.
- They click it. The product is added to their soonest upcoming event and the pill changes to Added with a check mark.
- Clicking Added removes their add, and the pill goes back to + Add To Event.
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. Removing your add leaves everyone else's in place.
The heart is for favorites only. It favorites and unfavorites a product for every customer, with or without an event, and never adds anything to an event.
For a customer with no upcoming event โ logged out, no event created, or the feature turned off โ the pill does not appear. Nothing changes for them.
Final Looksโ
Final Looks is the party's lineup: one card per member, each showing that member's main look.
Everyone in the party can see it at /party/<event id>/final-looks. A member with nothing assigned
yet shows an empty hanger tile instead.
Admins can change the order the members appear in:
- They tap Reorder in the header. The button becomes Done and a drag handle appears in the top-left of every card.
- They drag a card to a new position. The other cards move aside to make room.
- They tap Done. The new order is saved and everyone in the party sees it.
On a phone you have to press and hold a card before it will move. A quick drag scrolls the page instead. That is deliberate: the browser cannot tell a drag from a scroll until you hold. It catches people out and reads as "drag is broken", so it is worth saying up front when you hand the page to someone to test.
The Reorder button only appears for admins, and only when the party has more than one member. Members never see it. With a single member there is nothing to reorder, so the button is hidden rather than shown doing nothing.
While reordering, the rest of the card is switched off. The "..." menu is hidden and an empty card stops offering Assign Look, so a mis-tap cannot navigate away and lose the order being arranged. Both come back when Done is tapped.
Nothing is saved until Done. Entering reorder mode and changing nothing writes nothing. Leaving the page mid-reorder discards the arrangement.
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.
The pill always adds to the soonest upcoming event. A customer in several events cannot pick a different one from a product card. The event bar above the page names the event the pill uses.
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 Add To Event pill disappears.
There is no separate switch for Add 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 the home page or any collection or product page and click + Add To Event on a product card.
- Confirm the pill changes to Added, and the product appears in the event's Dressing Room.
- Click Added and confirm it changes back.
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 card, 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. Which event the pill uses 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 pill will not appear.
- 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, make sure you picked the person on your soonest upcoming 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 a "Something went wrong. Please try again." toast, and the pill stays as it was. They 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 derived from
API_GATEWAY_BASE_URL, so npm run codegen needs that env var set.
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 AddToEventButton consumes it on every product
surface including stories and specs.
EventSummary is a light shape โ publicId, name, date, orderByDate, eventType, role,
weddingRole, paletteColorIds, paletteColors, ownerGid and ownerFirstName. No products and
no members. Anything richer needs getEvent, which is per-event and server-only.
The last two of those are derived rather than stored, and loadEventSummaries adds them on top of
what the backend returns: resolveEventSummaries resolves paletteColorIds against the color
catalog into paletteColors, dropping ids with no match, and looks up ownerFirstName through the
Admin API. Both are paid for lazily โ the catalog is fetched only when some event actually has a
palette, and the owner's name only for a member still waiting on the owner to pick colors, which is
the one event bar state that names them. getEventSummaries on its own returns the raw rows with
neither field.
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.
The current event is derived, not stored: useCurrentEvent() returns selectCurrentEvent(events)
(app/lib/party/currentEvent.ts), the first summary whose date has not passed on the device clock.
The event bar and the Add To Event pill both use it.
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 provider does 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.
Add To Event implementationโ
The pillโ
AddToEventButton (app/components/AddToEventButton/) renders on ProductCard, PlpProductCard,
MiniProductCard and the PDP MediaGallery. It takes only { productId, className } and renders
null unless there is a current event, the page passes showsEventBar(pathname), and the product
GID is valid. In the added state its accessible name is "Added, remove from event", so a screen
reader says what the click does.
The gallery is the one surface that positions the pill itself, because it has no card to sit in. Both
layouts place it over the image and hide it on video, the same rule FavoriteButton follows there:
DesktopGallery passes the positioning through className and adds hidden when the main display
is not an image, which also required making its main-display wrapper relative. MobileSwiper wraps
the pill in its own positioned div and hides it when the active slide has a videoUrl, reusing the
favoriteLastSlideOffset translate so the pill shifts with the heart on the last slide.
One load per pageโ
The member's adds to the current event live in EventSummariesProvider, not in each card, so a PLP
makes one request instead of one per card. Its inner EventSummariesValue loads them through
useEventProductGids (app/hooks/useEventProductGids.ts, which also holds mutateEventProduct) and
exposes currentEventProducts (isLoading, isAdded, toggle) through useCurrentEventProducts().
The provider is the hook's only caller. It passes '' to the hook, which skips the request, when
there is no current event or the page has no event bar.
showsEventBar lives in app/lib/party/currentEvent.ts rather than in EventBar because the provider
needs it and EventBar imports the provider. Keeping it in EventBar would make the import circular.
Not optimisticโ
toggle waits for the write, then adds or removes only that product in the set. A failure leaves the
set untouched and the pill shows a toast. It ignores the list the action returns: two cards toggled
close together can get their responses back out of order, and replacing the whole set would let the
older response win. The in-flight state lives in each pill, not in the provider, since only the
clicked card needs it.
The heart (FavoriteButton) reads nothing from EventSummariesContext. Favorites and event adds are
separate controls.
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.
Reordering Final Looksโ
The write path is app/routes/api.party.reorder-members.ts, a PUT backed by the
reorderEventMembers mutation. authorizeEventApiAccess answers membership, and the route asks the
admin question itself, so members cannot reorder.
reorderedGids must be a complete permutation of the live roster. The backend assigns each
member's position straight from the array, and rejects duplicates, additions and removals with a
BAD_REQUEST. The route checks the same three things before calling, which is redundant for
correctness and kept only for the status code: without it a bad list comes back as a GraphQL error,
the helper turns that into false, and the caller sees a 500 for what is really a 400.
The no-op guard sits on both sides. The client skips the request when the order is unchanged, which is what "no unnecessary write" actually means; the route re-checks in case a submission arrives anyway.
The grid holds order and data separatelyโ
The grid is a React Aria GridList with useListData and useDragAndDrop, modelled on
favorites._index.tsx. Two things about it are not obvious, and both caused bugs:
React Aria caches the collection on the items array's identity. useListData seeds once from
event.members and never resyncs, so passing list.items straight to items freezes every card at
its mount-time values. Set a member's main look in the review flyout and the card keeps showing the
old one, because the render function is never called again. Resolving fresh data inside the render
function does not help for the same reason.
The fix is to derive items so its identity changes: the live event.members, sorted by the
position each GID holds in list.items, memoised on both. The drag list owns order, the loader owns
everything else, and a member added or removed while the page is open still appears or disappears.
Order has to be held client-side at all so it survives the save round-trip. Reading straight from
event.members would snap the cards back between Done and the revalidation landing.
dragAndDropHooks cannot be swapped in place. React Aria runs a different number of hooks
depending on whether the prop is present, so toggling it between undefined and the hooks object
throws Should have a queue. The GridList therefore carries a key that changes with the mode,
forcing a remount. favorites._index.tsx does the same.
A failed save surfaces as a toast and leaves the attempted order on screen, so the admin can retry
without re-dragging. The effect is keyed on the fetcher's data rather than a
derived boolean, because a boolean stays true across a second consecutive failure, so the dependency never changes and the
toast never fires again.
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โ
The heart publishes the standard FAVORITES_ADD / FAVORITES_REMOVE events. Adding a product to an
event or removing it publishes nothing yet.