mobile.fitprotracker.com, plus (2) the dormant deferred-deep-link hook bundled into the same app
release so it can be switched on later with no second store submission. Grouped by repo. Each change shows the exact file and
before/after.
Dev/dev, but every file below is unchanged on
Dev/dev β so these excerpts are current. Cut the migration branches fresh from latest
origin/main (production) β not from Dev/dev and not from the AB#29915 branches.
ServiceBusMonitorFunctions (ClientAppBatchInvitesHandler.cs schedules,
ClientAppInvitesHandler.cs sends) β POST /api/auth/{key}/register (Client.Web.Api/AuthController.Register);
code + CallBackUrl stored via ContactDb.UpsertContactEncodedInvites; password set at POST /api/auth/confirmUserEmail.
CallBackUrl points at {ClientAppDomain}/register (iliv.fit today) β A1 config flip repoints exactly this.
mobile.fitprotracker.com and (2) a 301 iliv.fit β mobile.fitprotracker.com
front-door redirect β plus a support pass to validate the register email templates (wording + links). The A2/A3 items below are cleanup, not go-live blockers.
Branch off latest origin/main (production). No business-logic changes β config + email templates + retiring one project.
One value drives every client callback URL (register + reset-password, via AuthController) and the waiver URL
(via WaiverService). No code touches it β only config. Current file uses local dev values:
// CURRENT (appsettings.json, dev defaults) "ClientAppDomain": "localhost:4200", "CorsAppDomain": "http://localhost:4200, https://localhost:4200, http://localhost:8100, ...", // PROD today (App Service config): ClientAppDomain = app.myfitprotracker.com ; CorsAppDomain includes https://*.iliv.fit
// UPDATED β set per environment (App Service / Key Vault holds the real prod values) "ClientAppDomain": "mobile.fitprotracker.com", "CorsAppDomain": "https://*.fitprotracker.com,https://*.myfitprotracker.com" // ^ add mobile.* (under *.fitprotracker.com); drop https://*.iliv.fit after cutover
AuthController.CreateEncodedCallbackUrl (register) and
CreateEncodedCallbackUrlForgotPassword (reset) already read _fptClientConfig.ClientAppDomain;
WaiverService too.OrganizationNotificationsTemplates /
LocationNotificationsTemplates), and 0 of 6 prod org rows + 0 of 4 prod location rows contain
iliv.fit. Their logo already points at a durable Cloudinary asset
(res.cloudinary.com/fitpromaster/.../FPTlogo.png). The iliv.fit strings the grep found live in
stale C# seed consts that don't ship. Earlier "update 3 templates" was overstated β most needs no change.| Item | Ships today? | Action |
|---|---|---|
| WelcomeToApp / Register / ResetPassword (DB templates) | Yes β already clean | none |
Email-address-changed notice β IlivFitUserEmail.UserNameModified (C# const used directly at EmailService.cs:795) | Yes β still has iliv.fit | the one real fix (fires only on email change) |
C# seed consts IlivFitWelcomeEmailBody, IlivFit/IlivFitResetPwd | No β stale defaults | hygiene: clean so they can't re-seed |
CORS https://*.iliv.fit in CorsAppDomain | config | drop it |
// THE ONE REAL FIX β IlivFitUserEmail.cs (email-changed notice; reuse the existing Cloudinary logo) src="https://iliv.fit/assets/images/logo/ilivfit_logo.png" // line 199 src="https://res.cloudinary.com/fitpromaster/image/upload/v1665700762/Emails/FPTlogo.png" You have recetly updated your email address on your iliv.fit account // line 293 You have recently updated the email address on your account Thank you for using iliv.fit // line 297 Thank you. // HYGIENE (stale seeds, not shipping) β same repoint/reword in IlivFitWelcomeEmailBody.cs + IlivFitResetPwd.cs
The Angular client portal served at iliv.fit. After cutover + the token grace window (A-step in Β§E), remove the project / its pipeline and deploy. No consumer remains once the PWA + redirect are live.
Branch off main. This holds the real code. All of B ships in the one store release the migration needs
(item B5 forces it) β so B7's dormant hook rides along for free.
What it's for: when a member taps the confirmation link, they land straight on "set your password" β no typing a code by hand.
Today the page only does manual code entry (validateCode() β getContactRegisterUrl β
/set-password with router state). Add a query-param short-circuit so the emailed/universal link lands straight on
set-password. Inject ActivatedRoute (not currently injected).
// constructor β add ActivatedRoute constructor(public authService: AuthService, private formBuilder: UntypedFormBuilder, private router: Router, public alertController: AlertController, private translateService: TranslateService, private route: ActivatedRoute) { addIcons({ keyOutline }); } ngOnInit() { this.logo = "/assets/image/brand/logo.svg"; this.art = "/assets/image/brand/bg_art.png"; // deep link / PWA URL: /register?code=..&sta=.. β skip manual entry const qp = this.route.snapshot.queryParamMap; const code = qp.get('code'), userId = qp.get('sta'); if (code && userId) { this.router.navigate(['/set-password'], { state: { code, userId } }); return; } this.registerForm = this.formBuilder.group({ code: ['', Validators.required] }); // manual entry stays as fallback }
No change to set-password.page.ts β it already reads code/userId from router
state and calls authService.doRegister({Code,UserId,Password}).
What it's for: when a link launches the app, this makes it open the right screen and keep the ?code&sta intact instead of dropping it.
The listener splits the URL on '.com'. That happens to work for mobile.fitprotracker.com, but it's
brittle and drops the fragment/edge cases. Parse it properly so path + query always survive:
// lines 76-83
App.addListener('appUrlOpen', (event: URLOpenListenerEvent) => {
this.zone.run(() => {
const slug = event.url.split('.com').pop();
if (slug) {
this.router.navigateByUrl(slug);
}
const url = new URL(event.url);
this.router.navigateByUrl(url.pathname + url.search); // e.g. /register?code=..&sta=..
});
});
What it's for: the short invite link (e.g. from an SMS with just a contact code) β expands it to the full registration link so short codes still work.
The short invite link. Mirror the web's RedirectToRegisterComponent: resolve the full URL from the backend and
forward. Add the route (follow the existing loadComponent pattern) and a tiny standalone page.
// app.routes.ts β add (param-route pattern already used by account/reset-password/:id)
{ path: 'r/:code', loadComponent: () => import('./pages/auth/redirect-to-register/redirect-to-register.page')
.then((m) => m.RedirectToRegisterPage) },
// new: src/app/pages/auth/redirect-to-register/redirect-to-register.page.ts
export class RedirectToRegisterPage implements OnInit {
showSpinner = true;
constructor(private route: ActivatedRoute, private router: Router, private authService: AuthService) {}
ngOnInit() {
const code = this.route.snapshot.paramMap.get('code'); // contactId
this.authService.getContactRegisterUrl(code).then(
res => {
const u = new URL(res.callBackUrl); // .../register?code=..&sta=..
this.router.navigateByUrl(u.pathname + u.search); // stay in-app (not window.location)
},
() => { this.showSpinner = false; } // error β offer "go to login"
);
}
}
What it's for: confirms password reset still works after sunset. It does β the app already has its own self-contained reset, so nothing needs building.
POST /api/auth/forgotPassword emails a
/reset-password?code={token} link β POST /api/auth/resetPassword consumes it. Used solely by
the iliv.fit portal.POST /api/auth/mobile/forgotPassword, which emails a 4-digit code (the
ResetPasswordMobile template); the app's Reset Password page submits code + new password to
POST /api/auth/mobile/resetPassword (24-hour expiry). No link, no web page.Retiring iliv.fit doesn't touch the app flow β it stops triggering the web link flow (nothing calls the web endpoints once the portal is gone). App and PWA users keep resetting via the 4-digit code exactly as today.
Only leftover = dead code. The web forgotPassword/resetPassword endpoints +
the link-style ResetPassword email template become orphaned β remove them when deleting
Client.Web.Client (A3). 2-min check: confirm nothing besides iliv.fit calls /api/auth/forgotPassword.
What it's for: the piece that makes a tapped link open the branded native app instead of a browser. This is the only part that requires a store release.
So the native apps (all brands) open the shared link. All point at the same domain, so the entitlement/manifest are identical across brands; only the association files enumerate every app.
iOS β ios/App/App/App.entitlements (currently only aps-environment):
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array><string>applinks:mobile.fitprotracker.com</string></array>
</dict>
Android β android/app/src/main/AndroidManifest.xml, add a verified VIEW filter to MainActivity
(keep the existing LAUNCHER filter):
<activity android:name=".MainActivity" android:launchMode="singleTask" android:exported="true" ...>
<intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="mobile.fitprotracker.com" />
</intent-filter>
</activity>
Association files served from mobile.fitprotracker.com/.well-known/ β list all brand
apps (bundle IDs verified from clients/*/):
// /.well-known/apple-app-site-association (no extension, application/json)
{ "applinks": { "apps": [], "details": [
{ "appID": "<TEAM_ID>.com.fitprotracker.client", "paths": ["/register","/set-password","/reset-password","/r/*"] },
{ "appID": "<TEAM_ID>.com.fitprotracker.client.fbbc", "paths": ["/register","/set-password","/reset-password","/r/*"] },
{ "appID": "<TEAM_ID>.com.fitprotracker.everybodybootcamp", "paths": ["/register","/set-password","/reset-password","/r/*"] },
{ "appID": "<TEAM_ID>.com.fitprotracker.fitprotrackerteam", "paths": ["/register","/set-password","/reset-password","/r/*"] },
{ "appID": "<TEAM_ID>.com.fitprotracker.enclavetrainingclub", "paths": ["/register","/set-password","/reset-password","/r/*"] },
{ "appID": "<TEAM_ID>.com.fitprotracker.ladiesboutique", "paths": ["/register","/set-password","/reset-password","/r/*"] },
{ "appID": "<TEAM_ID>.com.fitprotracker.upliftrevival", "paths": ["/register","/set-password","/reset-password","/r/*"] }
]}}
// /.well-known/assetlinks.json (one entry per Android package + its Play signing SHA-256)
[ { "relation": ["delegate_permission/common.handle_all_urls"],
"target": { "namespace": "android_app", "package_name": "com.fitprotracker.client",
"sha256_cert_fingerprints": ["<SHA256_client>"] } },
// β¦fbbc(com.fitprotracker.fbbc), everybodybootcamp, fitprotrackerteam, enclavetrainingclub, ladiesboutique, upliftrevival
]
upliftrevival's capacitor.config.ts appId has a
trailing space ('com.fitprotracker.upliftrevival ') β clean it or the appID won't match.
(2) fbbc iOS bundle (β¦client.fbbc) β Android package (β¦fbbc) β use the right value in
each file. Needed inputs: Apple <TEAM_ID> and each app's Play <SHA256>
(from mobile signing). Each brand app needs a store release to pick up the entitlement/manifest (Γ7).
What it's for: confirms the app lets members edit the same profile info the old portal did (address, emergency contact) so nothing is lost in the move.
iliv.fit had user/address + user/emergency-contact. Confirm the PWA profile
(/dashboard/tabs/profile) covers both; add if missing. Likely present β verify only.
What it's for: ships now but stays off β lets the "install the app and it already knows who I am" experience be switched on later from the server, with no extra store release.
/match, gets an empty/404 response, and falls through to the normal flow. No new plugins
(@capacitor/device + @capacitor/app already used).// new: src/app/core/services/deep-link.service.ts
async fingerprint(): Promise<string> {
const info = await Device.getInfo(); // @capacitor/device (already installed)
return [info.platform, info.osVersion?.split('.')[0],
Intl.DateTimeFormat().resolvedOptions().timeZone].join('|'); // signals stable webβnative
}
async resumeFromInstall(): Promise<void> { // call once on first cold launch, if no session
try {
const fp = await this.fingerprint();
const p: any = await this.http.post(`${environment.authUrl}/api/deeplink/match`, {},
{ headers: { 'X-Device-FP': fp } }).toPromise();
if (p?.code && p?.sta) this.router.navigate(['/register'], { queryParams: { code: p.code, sta: p.sta } });
} catch { /* endpoint absent or no match β normal flow (fail-safe) */ }
}
// app.component.ts initializeApp() β gated so it fires only on a genuine fresh install
if (Capacitor.isNativePlatform() && !this.authService.isAuthenticated /* + a first-run Preferences flag */) {
await this.deepLink.resumeFromInstall();
}
Branch off dev. Trivial: 4 hardcoded iliv.fit links in 2 staff onboarding help files. Same content in
the modern lib and its AngularJS-era duplicate.
// libs/dashboard/.../migration-check-list/steps-migration/step.sessions.html (lines 22, 39) // libs/legacy/src/app/billing/migration/onboarding/steps/step.sessions.html (lines 14, 31) β¦via our <a href="https://iliv.fit" target="_blank">booking app</a>. β¦via our <a href="https://mobile.fitprotracker.com" target="_blank">booking app</a>.
Not touched (intentionally): the isIlivFitUser flag (cosmetic field name; doesn't break) and
myfitprotracker.com hits (the staff SPA's own domain config, unrelated).
Server + web only β never touch the app store. Ship whenever you decide to light up B7. Reuses existing Redis.
[Function("StashDeepLink")] // PWA "Get the app" calls this before the store bounce
public async Task<HttpResponseData> Stash(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "deeplink/stash")] HttpRequestData req) {
var payload = await req.ReadFromJsonAsync<DeepLinkPayload>(); // { code, sta }
await _redis.GetDatabase().StringSetAsync($"dl:{Fingerprint(req)}",
JsonSerializer.Serialize(payload), TimeSpan.FromHours(1)); // short TTL, single-use
return req.CreateResponse(HttpStatusCode.NoContent);
}
[Function("MatchDeepLink")] // app B7 calls this on first launch
public async Task<HttpResponseData> Match(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "deeplink/match")] HttpRequestData req) {
var db = _redis.GetDatabase(); var k = $"dl:{Fingerprint(req)}";
var val = await db.StringGetAsync(k);
if (!val.IsNullOrEmpty) await db.KeyDeleteAsync(k); // single-use
var res = req.CreateResponse(HttpStatusCode.OK);
await res.WriteStringAsync(val.HasValue ? val.ToString() : "{}");
return res;
}
private static string Fingerprint(HttpRequestData req) { // must match B7's recipe
var ip = req.Headers.TryGetValues("X-Forwarded-For", out var f) ? f.First().Split(',')[0].Trim() : "";
var fp = req.Headers.TryGetValues("X-Device-FP", out var d) ? d.First() : "";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{ip}|{fp}")));
}
// ponytail: fingerprint is best-effort (gym Wi-Fi shares one IP); SMS numeric code is the safety net. Rate-limit these anon routes.
When the link opens in-browser and the user chooses to install: stash the payload, then redirect to the store. (Only needed the day you enable deferred deep linking; the PWA fallback works without it.)
const qp = this.route.snapshot.queryParamMap, fp = await this.deepLink.fingerprint();
await this.http.post(`${environment.authUrl}/api/deeplink/stash`,
{ code: qp.get('code'), sta: qp.get('sta') }, { headers: { 'X-Device-FP': fp } }).toPromise();
window.location.href = this.isIOS ? APP_STORE_URL : PLAY_STORE_URL;
| Item | Action |
|---|---|
| Azure config | Set ClientAppDomain=mobile.fitprotracker.com + CorsAppDomain per env (App Service / Key Vault) |
| PWA host | Point mobile.fitprotracker.com at the FPT.Client static build (SWA via CI-Client-StaticWebSite-Mobile.yml); serve /.well-known/* with correct MIME |
| DNS / redirect | 301 iliv.fit β mobile.fitprotracker.com for β₯10 days (in-flight tokens + bookmarks), then retire the domain |
| Store releases | Re-submit all ~7 branded apps (deep-link entitlement/manifest from B5 + B7 rides along) |
| Signing inputs | Apple Team ID + each app's Play signing SHA-256 (for the association files) |
Dev. Low risk, deploy first.main; build all ~7 apps; submit to stores. This is the long pole (store review).dev. Trivial.iliv.fit + delete Client.Web.Client (A3).Locked design for the next phase; builds on Phase 0/1. Mostly wiring services that already exist.
Activation code texted from the gym's own SMS number (sender branded for free). Reuse the existing mobile code flow β ContactService.GenerateCode issues + stores the code (already used by ResetPasswordMobile).
POST /api/auth/mobile/activate that validates the code (reuse RecoveryInformation) and returns a JWT β mirror ResetPasswordMobile. β
pattern exists.autocomplete="one-time-code" for iOS autofill + Android SMS Retriever hash in the message body. β small new screen.The onboarding SMS carries fitpro.io/a/{code} (FPT.UrlShortener Β· AppDownload.cs) β already device-aware + per-brand (encodes contactId|locationId β resolves the gym's branded app + the right store). β
built, already in the Welcome email. No new store-routing code.
Every member's SMS leads with fitpro.io/a/{code}, which auto-routes to the right app for their gym β the branded app if the tenant bought it, else the default Fit Pro Tracker app. So there's no per-tenant SMS branching β the smart link resolves it. Include a "start in browser" option too. Do not hard-gate install β the PWA is the no-lose fallback.
The PWA fallback is runtime-themed per gym (Β§H3) so branded tenants stay branded on the web too β page skinned to the gym, URL shared (mobile.fitprotracker.com). Lead hard with the app; the web catches everyone who doesn't install. (A per-brand web URL is a further, separate DNS-per-tenant option if any tenant needs the address branded too.)
Today the Digital Documents waiver SMS sends doc.fitprotracker.com/document/b/{token} (~90 chars β 2β3 SMS segments). Add a document route to the shortener (fitpro.io/d/{code} β encrypt/redirect to the doc URL; /f is the legacy forms route, so this is a NEW route on the same service) and point the document send at it.
ShortLinkService) β open/click analytics per member β see who tapped the waiver / download link, resend to non-openers, measure activation drop-off. Funnel visibility we don't get from the raw doc.fitprotracker.com link.Goal: from "I said yes" to "booked & cleared" in the fewest taps, on the surface they're already on. Best-in-class = remove every friction we control; the one step we can't delete is the OS's own app install for a brand-new user β so we don't require it (goal = native app; web is the no-lose fallback), and make the app a one-tap upgrade.
SMS code (from the gym's number) β PWA β OTP autofills β in β waiver (fitpro.io/d) β book β in-app / kiosk-QR check-in. OTP autofill works on the web and in the app:
| OTP autofill | iOS | Android |
|---|---|---|
| Web / PWA | autocomplete="one-time-code" in Safari β 1-tap suggestion above the keyboard | WebOTP API (navigator.credentials.get({otp})) β needs the @domain #code SMS format β auto-reads, ~0-tap |
| Native (Capacitor) | Same one-time-code attribute works in the WKWebView β 1-tap | SMS Retriever API (Capacitor plugin) + a per-app 11-char hash in the SMS β auto-reads, 0-tap. Hash is per brand (7 apps β 7 hashes in their SMS templates). |
fitpro.io/a/{code} β right branded app + store β install β open β OTP autofills β in. To kill the "fresh install doesn't know me" re-identify step, add deferred deep linking (Β§D DIY, or Branch) β carries contactId through the install so the app opens already recognizing them. Deferred β OTP autofill covers the gap; add later only if the post-install re-tap measurably hurts. iOS match ~70β90% post-ATT; OTP autofill is the reliable fallback when the match misses.
Runtime-theme the PWA (resolve org from the code/session β apply the brand's colors + logo) so branded tenants get a fully-branded web leg β no need to force an install just to look branded.
Investigated 2026-07-24 β two halves: theming today is 100% build-time (pipeline copies clients/<name>/theme/*, one bundle per brand). (a) CSS half β cheap (~1β2d): colors are already Ionic CSS custom properties (--ion-color-primary), so a runtime service can override them (setProperty) with no recompile. (b) Data half β net-new, the bulk: the API exposes no per-org brand colors/logo β add brand fields on org/location + expose on login/select-location Β· logo becomes a Cloudinary URL (not the hardcoded /assets/image/brand/logo.svg) Β· admin UI for owners Β· DB columns. Native identity (bundle IDs/icons/Firebase/Trapeze) stays build-time regardless. Since native apps are already branded and the goal is native, this only serves the branded-member-on-web edge case β defer until that web usage justifies the net-new data + admin work; ships PWA-first if pursued.
fitbodychandler3.fitprotracker-dev.com/sessions/calendar each class shows a "Join class" button β https://fitproclient-dev.com/kiosk/registrationClass β a new lead is immediately redirected to the iLiv.fit login wall (/?returnUrl=/dashboard/client) showing iLiv.fit branding, not the gym. Register there only activates a staff-provisioned contact via a code. So a prospect who clicks "Join class" dead-ends β no self-serve path. This is a lost lead + a branding leak, and it's the flow to fix (not preserve).
fitbodychandler3.fitprotracker-dev.com/sessions/calendar (Zone 6 Fitness). Each class shows a Join class button β {ClientAppDomain}/kiosk/registrationClass.
fitpro.io/a), PWA fallback. Lead-capture floor so nobody dead-ends; gym-branded throughout. Exemplar = Mariana Tek "single-step book-and-buy" (class context pre-filters the offers, purchase auto-books).
Gym-branded throughout Β· lead-capture floor so no one dead-ends Β· web-first (Apple/Google Pay) then app hand-off for retention.
UI patterns worth mirroring (adapted to each gym's brand):
time Β· duration β instructor avatar β class title (bold) + instructor + room β single right-aligned CTA.Competitors (Mariana Tek / Glofox) let a gym drop a widget on their own site. We can too β and we already have precedents (fpt-journey.js CDN loader + the anonymous public calendar + subdomain multi-tenancy). Live concept: embed-demo.html (a mock gym site with the widget iframed in).
| Option | Gym drops | Effort | Reuses | Net-new |
|---|---|---|---|---|
| 1 Β· iframe (recommended) | <iframe src=".../embed/calendar?location=KEY"> | M ~1β1.5d | calendar (anonymous), single-route SPA (HomeController.Index catch-all) | a chrome-less /embed/calendar route (UI-Router state in appClient.route.ts) Β· location+signed-token param Β· CORS in Startup.cs (none today) Β· iframe auto-resize (postMessage) |
| 2 Β· script insert | <script src=".../widget.js" data-location=KEY> | XL ~2β3d | fpt-journey.js loader pattern; leadWidget component | standalone widget bundle (Gulp task) β fights AngularJS single-app-per-page; UMD/namespacing |
| 3 Β· hosted page (MVP) | a link on their site | S ~0.5d | subdomain multi-tenancy already resolves tenant | one /embed/calendar-style route auto-selecting the location β no cross-origin at all |
appInterceptor.service.ts only fires for *.fitprotracker hosts), so the location key + signed token must be passed in the embed snippet. No CORS / X-Frame-Options / CSP is configured today (Startup.cs) β additive, low-risk to add. Payment stays SAQ-safe by rendering the hosted-iframe checkout (FPT Pay) so card data never touches the gym's page. Recommendation: ship Option 3 (hosted page) as the pilot, then Option 1 (iframe) for true on-site embed; defer Option 2. Branch cut: feature/new-lead-signup-embed (FitProTracker.Public).
iframe-resizer, the standard lib. License gotcha: v5 is GPLv3 / paid commercial β GPL is transitive, so embedding it on a customer's closed-source site needs a paid commercial license; v4 is MIT (free). The demo uses v4 (MIT); a ~10-line homegrown postMessage resizer is the license-free fallback. Proven working desktop + mobile in embed-demo.html β retires the iframe auto-resize risk at concept level.Assessed against the fresh main clones. Recommended approach = a custom modal on the public calendar (not the journey/funnel system β journeys can't carry class context without fighting the framework = VERY HIGH).
| Reusable as-is | Needs adaptation | Net-new |
|---|---|---|
|
β’ Bootstrap-4 jQuery modals (already how the site does popups β no new lib) β’ Zift proxynization payment logic ( checkout/zift.component.ts) β global ProxynizationAPIβ’ Anonymous class-register ( POST /api/public/contactByClass) + kiosk endpoints (classesByDate, kioskLocation)β’ Anonymous lead create ( POST /api/public/addLeadContact)
|
β’ Extract the Zift form from checkout/zift.html into a modal-sized template (test tokenization callback in modal DOM)β’ Calendar component: open modal w/ clicked class context ( locationId + classInstanceId) instead of the redirect (calendar.*.ts ilivEndpoint())β’ addLeadContact response should return the new contactId (today you'd re-lookup by email)
|
β’ GET /api/public/location/{id}/offers β list a location's purchasable intro offers / trials / memberships (today product lookup is promo/funnel-key-scoped, NOT location-scoped) β the "pick an offer" menuβ’ POST /api/public/registerGuestForClass β atomic create-lead + book-class (or chain the two existing calls)
|
Top risks / unknowns: (1) ProxynizationAPI global stays reachable across modal destroy/recreate β keep the callback at page level; (2) server-side class-capacity enforcement on concurrent guest books (verify contactByClass checks capacity); (3) mobile modal UX β prefer a 3-step wizard (offer β info β pay) over one long form; (4) design the payment slot as a swappable binding so the SAQ hosted-iframe migration (FPT Pay) drops in without a rewrite.