Reference
Join to Save

Join to Save

Part of @cobuntu/embed-react: headless, data via props, links through your router.

When a visitor buys a ticket whose tier has a lower member price on a free tier they can join instantly, invite them to join first. useJoinToSave sequences the flow and JoinToSaveModal draws it. Your server makes the API calls; the Join to Save guide has the routes.

import { JoinToSaveModal, useJoinToSave, readJoinToSaveResume } from "@cobuntu/embed-react";
 
const jts = useJoinToSave({
  tierId, preview, signedIn,
  signIn: (returnTo) => router.push(`/login?next=${encodeURIComponent(returnTo)}`),
  loadForm: (segmentId) => fetch(`/api/join/form?segment=${segmentId}`).then((r) => r.json()),
  apply: (segmentId, answers) => post("/api/join", { segmentId, answers }),
  loadOnboardingUrl: () => fetch("/api/join/onboarding").then((r) => r.json()).then((d) => d.url),
  proceed: buy,                       // your original purchase
  resume: readJoinToSaveResume(location.search) === tierId,
});
 
function onBuy() {
  if (jts.intercept()) return;        // the offer opened
  buy();
}
 
{jts.offer && (
  <JoinToSaveModal
    step={jts.step} community={{ name, iconUrl }}
    memberPrice={jts.offer.memberPrice} listPrice={jts.offer.listPrice} currency={jts.offer.currency}
    locale="en-IE" loading={jts.loading}
    onJoin={jts.join} onContinue={jts.continueAtList} onClose={jts.close}
    form={jts.form} answers={jts.answers} onAnswer={jts.setAnswer} onSubmitForm={jts.submitForm} onBack={jts.back}
    onboardingUrl={jts.onboardingUrl} brandColor="#6b4eff"
  />
)}

The offer shows only when the preview has no buyerOverride (already a member), the viewer has no staff access, and bestOffer.joinable is true. Continue and the close button both hide it for the rest of the visit (sessionStorage).

useJoinToSave option
tierId, preview, signedInThe tier, its member-pricing preview, and whether the visitor has a session.
signIn(returnTo)Send a signed-out visitor to sign in. returnTo is the current URL with ?jts=<tierId>.
loadForm(segmentId)The tier's form (segments.form).
apply(segmentId, answers?)me.segments.apply with source: "event_join_upsell". Resolve on JOINED, throw otherwise.
loadOnboardingUrl()Optional. me.onboarding.get() then form.embedFormUrl, for external forms.
proceed()The original purchase. Runs after a join (member price) or on Continue (list price).
resume, onResumedResume the join after sign-in, then clear the key from the URL.
enabled, onError, currentUrl, storageKeyOptional.
paid: { applyUrl(segmentId), plansUrl(segmentId), navigate? }Optional. Offer paid memberships too. Join links to applyUrl (joinPath apply or checkout) or plansUrl (changeTier) with ?jts=<tierId>&returnTo=<this item>.
loadPreview(), pollIntervalMs, pollTimeoutMsOptional. With resume, after a paid join: poll the preview (with the session) until the member price applies, then proceed().

It returns offer (with kind: "free" or "paid", and for paid membership and joinPath), shouldOffer, step (closed, offer, form, onboarding, confirming, pending), loading, error, form, answers, setAnswer, onboardingUrl, paidHref, and the actions intercept, join, submitForm, continueAtList, close, dismiss, back, confirm.

For a paid offer, pass kind="paid", membership={jts.offer.membership} and joinHref={jts.paidHref} to JoinToSaveModal. On your apply or plans page, readJoinToSavePaidResume(location.search) gives { tierId, returnTo } (a same-origin path, or null).

Helpers: joinToSaveOffer(preview, { paid? }), membershipPrice(membership), joinToSavePaidHref(page, { tierId, item }), readJoinToSavePaidResume(search), sanitizeReturnTo(value), waitForMemberPrice(load, { intervalMs?, timeoutMs?, signal? }), formatJoinPrice(cents, currency, locale?) ("€5", "€5.50"), joinToSaveReturnTo(url, tierId), readJoinToSaveResume(search), clearJoinToSaveResume(url), joinStepFor(form), missingJoinFields(fields, answers).

JoinToSaveModal labels (defaultJoinToSaveLabels): title(name), subtitle(name), priceLabel, freeNote, perkPrices(name), perkBelong(name), join, joining, continueAt(price), close, formTitle, formSubtitle(name, price), formBack, formSubmit, selectPlaceholder, onboardingTitle, onboardingNote, onboardingDone, and for paid offers joinPaid(name), paidNote(primary, yearly), perCycle(price, cycle), confirmingTitle, confirmingNote(name), pendingTitle, pendingNote, and memberPriceText(formatted, cents) (for example "free" when the member price is 0). Keep the copy positive: the list price appears once, on the Continue button, with no comparison.

Class map

See the class map conventions.

Join to Save

ClassElementState attributes
cb-jtsRoot (--cb-jts-brand set from brandColor)data-step="offer | form | onboarding", data-loading
__backdrop, __frame, __surface, __closeDialog and the round close button
__offer, __logo (__logo--initial without an icon), __title, __subtitleHeader
__price-box, __price-label, __price, __price-noteThe member price
__perks, __perk, __perk-checkWhat belonging gives
__actions (--row), __join, __continue, __back, __errorButtonsdisabled while loading
__form, __fields, __field, __field-label, __required, __field-hint, __inputNative formfield: data-type
__onboarding, __iframeExternal onboarding form