iliv.fit β†’ PWA Migration β€” Implementation Spec

FitProTracker Β· client onboarding Β· complete code changes for review Β· 2026-07-23 Β· reconciled 2026-07-27
What this is. Every code change to (1) sunset the iliv.fit client portal and move clients onto the PWA at 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.
Source freshness (verified 2026-07-23). Working copies are 0/0 with their origin branches. The backend & Front-Office feature branches sit behind 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.
Legend. BACKEND FitProTracker   MOBILE FitProTracker.Mobile (FPT.Client)   FRONT-OFFICE staff SPA   DORMANT ships dark in the store release   DEPLOY-LATER server/web only, no store
Code-validated 2026-07-29 (team review + Explore agents). The invite pipeline is real and located: the "Function project" = 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.
Minimal go-live (Christian's view): the register + password screens ALREADY live in the app, so retiring iliv.fit needs only (1) repoint the public calendar to 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.
Corrections: confirm token β‰ˆ24h (ASP.NET Identity default), NOT 10 days; today the system sends both SMS + email (no email-only toggle in code).

Repo A β€” FitProTracker (backend) BACKEND

Branch off latest origin/main (production). No business-logic changes β€” config + email templates + retiring one project.

A1 Β· Config flip Client.Web.Api/Web.Api/appsettings.json + per-env App Service / Key Vault

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
No code change. Verified: AuthController.CreateEncodedCallbackUrl (register) and CreateEncodedCallbackUrlForgotPassword (reset) already read _fptClientConfig.ClientAppDomain; WaiverService too.

A2 Β· Email cleanup Fpt.Models/Models/Email/* + config

Verified against dev AND prod (2026-07-23) β€” the live email templates are already clean. The onboarding/register/reset bodies render from the DB (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.

ItemShips today?Action
WelcomeToApp / Register / ResetPassword (DB templates)Yes β€” already cleannone
Email-address-changed notice β€” IlivFitUserEmail.UserNameModified (C# const used directly at EmailService.cs:795)Yes β€” still has iliv.fitthe one real fix (fires only on email change)
C# seed consts IlivFitWelcomeEmailBody, IlivFit/IlivFitResetPwdNo β€” stale defaultshygiene: clean so they can't re-seed
CORS https://*.iliv.fit in CorsAppDomainconfigdrop 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
No re-hosting needed. The durable logo already exists on Cloudinary β€” just point the stale const at the same URL. No new asset, no CDN work. And no bulk DB update: prod template rows are verified clean.

A3 Β· Retire the portal Client.Web.Client (project)

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.

Repo B β€” FitProTracker.Mobile / FPT.Client (client app) MOBILE

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.

B1 Β· Register page reads code + sta from the link src/app/pages/auth/register/register.page.ts

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.

B2 Β· Fix the deep-link listener src/app/app.component.ts

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.

B3 Β· Port the /r/:code invite redirect src/app/app.routes.ts + new page

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.

B4 Β· Reset-password β€” NO ACTION (verified) app uses its own mobile reset flow

Verified: not a blocker, nothing to build. There are two independent reset systems, and the app never used the web one:

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.

B5 Β· Native universal / app links ios entitlements Β· AndroidManifest Β· /.well-known Β· Γ— brands

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
]
Two data quirks to fix first. (1) 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.

B6 Β· Profile sub-pages β€” verify FPT.Client profile

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.

B7 Β· Dormant deferred-deep-link hook DORMANT new deep-link.service.ts + bootstrap call

Ships dark in the B5 release. Inert until the Β§D server endpoints exist β€” it calls /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();
}

Repo C β€” FitProTracker.Front-Office (staff SPA) FRONT-OFFICE

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).

D β€” Deferred-deep-link: deploy-later parts DEPLOY-LATER

Server + web only β€” never touch the app store. Ship whenever you decide to light up B7. Reuses existing Redis.

D1 Β· Stash + match Functions FitProTracker Β· public-fpt-functions/DeepLink/DeepLinkFunction.cs

[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.

D2 Β· Web "get the app" stash trigger FPT.Client (PWA mode)

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;

E β€” Non-code (ops)

ItemAction
Azure configSet ClientAppDomain=mobile.fitprotracker.com + CorsAppDomain per env (App Service / Key Vault)
PWA hostPoint mobile.fitprotracker.com at the FPT.Client static build (SWA via CI-Client-StaticWebSite-Mobile.yml); serve /.well-known/* with correct MIME
DNS / redirect301 iliv.fit β†’ mobile.fitprotracker.com for β‰₯10 days (in-flight tokens + bookmarks), then retire the domain
Store releasesRe-submit all ~7 branded apps (deep-link entitlement/manifest from B5 + B7 rides along)
Signing inputsApple Team ID + each app's Play signing SHA-256 (for the association files)

F β€” Sequencing

  1. Backend (A1 config, A2 emails) β†’ PR to Dev. Low risk, deploy first.
  2. Mobile (B1–B3, B5, B6 + dormant B7) β†’ PR to main; build all ~7 apps; submit to stores. This is the long pole (store review).
  3. Front-Office (C) β†’ PR to dev. Trivial.
  4. Cutover: point the domain, flip config, set the 301. Verify a real membership purchase β†’ link β†’ app opens branded β†’ confirm.
  5. Grace window passes β†’ retire iliv.fit + delete Client.Web.Client (A3).
  6. Phase 2: SMS-first activation + app-first routing (section G) β€” reuses existing services; the app "enter code" screen rides the store release.
  7. Later, optional: deploy D1/D2 to light up deferred deep linking (no store release).

G β€” SMS-first activation & app-first (Phase 2 design)

Locked design for the next phase; builds on Phase 0/1. Mostly wiring services that already exist.

G1 Β· SMS-first activation (OTP) Fpt.Standard ContactService + FPT.Client

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).

G2 Β· App-first via the existing smart link reuse fitpro.io/a/{code}

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.

G3 Β· Drive to the native app for everyone (web = fallback) SMS content + runtime-skinned PWA

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.)

G4 Β· Shorten the waiver link + track opens FPT.UrlShortener + Digital Documents send path

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.

H β€” Activation feasibility & the frictionless target (iOS vs Android)

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.

H1 Β· The frictionless path β€” SMS-first, zero password (web or app)

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 autofilliOSAndroid
Web / PWAautocomplete="one-time-code" in Safari β†’ 1-tap suggestion above the keyboardWebOTP 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-tapSMS 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).

H2 Β· The app as a frictionless upgrade (branded, push)

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.

H3 Β· Branded tenants stay branded on the web too DEFERRABLE

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.

H4 Β· What we remove vs. the irreducible floor

I β€” New-lead: public schedule β†’ signup (design + complexity)

Verified live (dev, 2026-07-29). On 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).
Public sessions calendar with Join class buttons
1 Β· Public calendar β€” fitbodychandler3.fitprotracker-dev.com/sessions/calendar (Zone 6 Fitness). Each class shows a Join class button β†’ {ClientAppDomain}/kiosk/registrationClass.
↓ new lead (no account) is redirected to ↓
iLiv.fit-branded login wall dead-end
2 Β· The dead-end β€” bounced to the i liv.fit-branded login wall (not the gym's brand). "Register" needs a staff-issued code, so a real prospect can't get in. Lost lead + branding leak. (captured live on dev 2026-07-29)
Target (competitor Pattern A β€” see research doc Β§5b). Click class β†’ show that location's intro offers / trials / memberships (intro offer as hero) β†’ guest checkout (account created at sale) β†’ auto-book the clicked class β†’ SMS-OTP activation β†’ app (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).
Public calendar → tap a class→ That location's intro offers / memberships→ Guest checkout (account at sale)→ Auto-book the clicked class→ SMS-OTP activate → app

Gym-branded throughout Β· lead-capture floor so no one dead-ends Β· web-first (Apple/Google Pay) then app hand-off for retention.

Best-in-class reference β€” Mariana Tek (mirror this)

Mariana Tek public class schedule UI
Mariana Tek's public schedule (their published UI). Clean/editorial, one clear CTA per row.

UI patterns worth mirroring (adapted to each gym's brand):

"Mirror them" = build our public calendar + intro-offer sheet in this visual language, gym-skinned. The single-step move (tap class β†’ offer sheet pre-filtered to that class β†’ guest checkout β†’ auto-book) is the interaction to copy; the row/CTA layout above is the look. Clickable mock (both variants + gym-skin swatches): new-lead-signup-mock.html β€” walk it before build.

Embed on the gym's own website β€” feasibility demo: embed-demo.html

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).

OptionGym dropsEffortReusesNet-new
1 Β· iframe (recommended)<iframe src=".../embed/calendar?location=KEY">M ~1–1.5dcalendar (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–3dfpt-journey.js loader pattern; leadWidget componentstandalone widget bundle (Gulp task) β€” fights AngularJS single-app-per-page; UMD/namespacing
3 Β· hosted page (MVP)a link on their siteS ~0.5dsubdomain multi-tenancy already resolves tenantone /embed/calendar-style route auto-selecting the location β€” no cross-origin at all
Key constraint (validated): on the gym's own domain the site can't derive the tenant (its subdomain logic in 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).
Auto-resize (no inner scrollbar, incl. mobile) = 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.

Complexity β€” popup/modal on the public calendar T-shirt: M Β· ~5–8 dev-days

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-isNeeds adaptationNet-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.

Business decision needed first (blocks build): do gyms let a lead book before buying (free-trial booking) or must they purchase an intro offer first? Industry answer = the intro offer IS the entry product (often free / new-client-gated), configured per gym β€” matches "different gyms, different ways." Confirm the per-location offer menu + eligibility rules with Christian/support before building the two net-new endpoints.
Bottom line. Phase 0 = 3 repos (backend config + email strings Β· Front-Office 4 links Β· mobile ~5 small edits + 1 tiny page + native link config across brands). B7 adds ~40 dormant lines so install-resume switches on later with no second app-store cycle. Phase 2 (SMS-first + app-first, Β§G/Β§H) is mostly wiring existing services β€” the smart link, onboarding SMS, mobile OTP and branded apps already exist; net-new is a small "enter code" screen + OTP autofill (iOS ~free, Android SMS-Retriever plugin) + two short-link routes + a routing flag + runtime PWA theming. The best-in-class, zero-touch finish (deferred deep linking) is deferred; OTP autofill covers it.