A French B2B loyalty mobile app for CSE (works council) employees. Merchants offer discounts to enrolled employees who scan a QR code in-store. React Native via Expo on iOS + Android, plus a Next.js web companion that handles deep-link fallback and share previews. Four distinct user roles (guest / employee / merchant / admin), two auth strategies running in parallel, and the closest thing I've shipped to a real consumer-facing mobile product.
Context
CSE in France is the works council — every company over 50 employees has one, and it negotiates benefits for its members. Club Azur Avantages is positioned as a discovery + redemption layer on top of that benefit network. Constraints:
- Mobile-first by definition. A merchant onboarding on their phone, an employee scanning at the counter, an admin browsing merchant submissions on the go. The web companion only exists to support deep links and share previews — every primary workflow ships native.
- Two auth tracks in one app. Employees have no corporate email; they log in via a CSE code + password. Merchants and admins use Supabase Auth. Both flow through the same Redux slice with a polymorphic user object.
- App Store + Play Store from day one. That forced the CI/CD investment early — EAS Build pipelines with TestFlight auto-submission landed before V1 even shipped.
- Single Supabase project across dev/preview/prod. Same URL, same anon key. A pragmatic choice for a solo build, but a real risk — see retrospective.
Stack
| Layer | Choice | Why this one |
|---|---|---|
| Mobile framework | Expo SDK 54 / React Native 0.81 / React 19 | Managed workflow + EAS Build = solo-dev velocity, OTA path open |
| Mobile state | Redux Toolkit (5 slices: auth, search, map, favorites, scan) | Explicit, debuggable, fits the multi-role complexity |
| Mobile UI | NativeWind + RN Paper + linear-gradient | Tailwind muscle memory transfers to RN |
| Maps | react-native-maps + Google Maps + Places API | Cheapest at low volume — turned out to be the trickiest piece of the stack |
| Web | Next.js 16 + React 19 | Server components for OG metadata on share previews |
| Backend | Supabase (DB + Auth + Edge Functions + Storage) | Single project covers four needs |
| Monitoring | Sentry RN | Installed late — see below |
| CI/CD | GitHub Actions + EAS Build, auto-submit to TestFlight on develop push | Free tier, matches Expo workflow |
| Deep linking | .well-known/apple-app-site-association + assetlinks.json self-hosted | No Branch.io, no third-party — just web + signed Android fingerprints |
The Android markers chase
2026-03-28 to 2026-04-02. Four fixes, five days, one bug.
Custom merchant markers on the map are the core discovery surface — every employee opening "around me" sees them. When they broke on Android during preview-build testing, basically the product stopped working. iOS rendered fine. Android was a parade of failure modes that each looked like the bug until the next one surfaced.
Cause 1: expo-linear-gradient doesn't serialize into the Android bitmap snapshot
React Native Maps on Android rasterizes custom markers to a bitmap. The bitmap snapshot ignores LinearGradient components — they render fine in normal RN views, but in marker snapshots, you get nothing where the gradient should be. The fix was demoting the gradient to a flat background color inside the marker. The brand orange gradient (#FF8534 → #FFB574) survives elsewhere in the app, just not inside markers. b448005
Cause 2: Google Maps API key missing for Android after a billing rotation
Markers were still half-broken because the Android-specific Maps key had silently rotated when I enabled billing on the Google Cloud project. iOS picks up its key from a separate Info.plist field, which is why it kept working. Added EXPO_PUBLIC_GOOGLE_MAPS_ANDROID_API_KEY to the EAS profile. 491c4b9
Cause 3: overflow: 'visible' + elevation breaks marker bitmap bounds
Markers were now appearing but clipped at the edges. The badge sticking out of the marker frame (showing the discount percentage) got cropped because the snapshot system uses the layout box and ignores overflow: 'visible'. Restructured the marker so the badge lives inside the bounding box. 86d7dcc
Cause 4: tracksViewChanges race condition with image loading
This was the most annoying one. Markers would render correctly the first time but then disappear on map re-renders. tracksViewChanges controls whether RN Maps re-snapshots the marker — if it flips to false before the hero image inside the marker finishes loading, you get a snapshot of an empty box and that's what stays. Fixed by gating tracksViewChanges on both mountReady and a separate imageLoaded flag, and adding an image prefetch service with a 15-image / 5-second budget to warm the cache after session restore. 03d4aed
The final marker file is here. What looks like a 200-line component is the residue of those five days. Sentry was installed in the same window — 1ab6b51.
Shipping without telemetry
Sentry came in on 2026-03-28, roughly four months after the first TestFlight build. Before that, the app was in the wild — TestFlight then App Store v1.0.0 through v1.0.3 — with console.error as the only observability surface. The Android marker bug is the one I actually caught. The honest question is what else shipped during those months that I never found out about because nothing was reporting it. That's the part of the project that nags me most when I look back.
Worth reading
create-merchant-account/index.ts— admin-gated edge function with atomic rollback on partial failuremerchantService.ts— query builder with accent-folding, day-of-week overlap, and a schema-migration fallbackShareCard.tsx— off-screen 1080×1350 rendering with image retry and gradient fallbackscans_production.sql— RLS with threeEXISTSsubqueries to prevent orphan scans at the DB layerweb/app/merchant/[id]/page.tsx— the entire reason the Next.js companion exists
Retrospective
- Sentry from day one, or any equivalent. Four months of production exposure with no telemetry is the lesson that follows me from this project. The marker bug got caught because I was actively testing on Android — that's not how telemetry should work.
- Single Supabase project across all envs. Same URL and key in dev, preview, prod. Today I'd split that — preview builds writing to prod data is a real risk that just hasn't bitten yet.
react-native-mapsis brittle on Android. Four fixes in five days plus a billing-related API key rotation plus a separate crash-on-rapid-tap incident means I've spent a disproportionate amount of time keeping that one dep happy. At today's traffic, a migration toexpo-mapsor Mapbox would probably pay for itself.- Two-track auth. Mixing CSE-code lookup and Supabase Auth in one Redux slice was the right call for shipping V1 fast, but the polymorphic user type leaks conditional logic into every screen that needs to know "who am I?". Folding the CSE check into a Supabase Edge Function once the merchant/admin flows stabilized would shrink the surface meaningfully.
- Auto-submit-to-TestFlight on every
developpush is too wide a gate. Convenient when iterating fast, dangerous the moment a bad commit lands. A manual approval step costs nothing.
build
Workflow: spec → plan-first session → parallel subagents → automated review → manual call on the gray areas. Production decisions, architecture, debugging, and incident response are mine. Code generation is the agent's. The portfolio itself documents this workflow in /projects/portfolio.