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, signedIn | The 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, onResumed | Resume the join after sign-in, then clear the key from the URL. |
enabled, onError, currentUrl, storageKey | Optional. |
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, pollTimeoutMs | Optional. 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
| Class | Element | State attributes |
|---|---|---|
cb-jts | Root (--cb-jts-brand set from brandColor) | data-step="offer | form | onboarding", data-loading |
__backdrop, __frame, __surface, __close | Dialog and the round close button | |
__offer, __logo (__logo--initial without an icon), __title, __subtitle | Header | |
__price-box, __price-label, __price, __price-note | The member price | |
__perks, __perk, __perk-check | What belonging gives | |
__actions (--row), __join, __continue, __back, __error | Buttons | disabled while loading |
__form, __fields, __field, __field-label, __required, __field-hint, __input | Native form | field: data-type |
__onboarding, __iframe | External onboarding form |