All notable changes to this app are recorded here. Newest entries on top.
+2348023456704) show as 0802 345 6704 everywhere they appear — customer profile, customers list, order detail, new-order search, invoice, public tracking page, and post-login customer dashboard. US numbers render as (555) 123-4567. Other countries keep the +countrycode prefix so the display stays readable internationally. The stored database value stays in E.164 so SMS, tel: links, and the portal phone-lookup keep working across regions. The Edit Customer form also shows the local format — your normalize_phone() helper already accepts the loose input, so submitting 0802… saves correctly as +234….format_phone_display(?string $phone): string in [apps/drycleanpro/helpers.php](helpers.php). Pure regex match on ^\+?234(\d{10})$ and ^\+?1(\d{10})$; any other shape returns unchanged.PS.formatPhoneDisplay(phone) in [core/assets/js/app.js](../../core/assets/js/app.js) (same regex, mirror of the PHP helper).portal.php and customer.php), added a small inline phoneFmt() copy next to the existing esc() / money() helpers in the same <script> block.views/customer-profile.php, views/customers.php, views/order-detail.php, views/new-order.php (search + review), views/location-select.php, views/partials/settings-sections.php (staff list), views/public/invoice.php (entity phone + customer phone), views/public/customer.php (account-tab phone + support phone), views/public/portal.php (greeting + no-orders empty state).tel: and wa.me/ hrefs continue to use the raw E.164 value (otherwise dialer apps and WhatsApp's deep-link strip the prefix incorrectly).</p>\ : ''} — sat between two valid lines in the payment-history block of views/order-detail.php. The orphan backtick prematurely closed the JS template literal, and the trailing : ''} then read as a stray : token, killing the entire <script> block and leaving every section locked on its loading skeleton. Removed the orphan; the rendered JS now parses clean (verified with node --check` against the live HTML output). The same orphan was present in the sibling apps PropertyPro, LogisticsRoute, and RealtyManager order-detail views — fixed there too.</p>\ : ''} between the payment date <p>` and the reference-note conditional.apps/{logisticsroute,propertypro,realtymanager}/views/order-detail.php.class="hidden" plus inline style="display:flex". The inline display:flex won the CSS specificity fight, so Tailwind's .hidden { display: none } (when it loaded at all) was silently overridden. Switched the strips to inline style="display:none" by default and let the JS swap style.display directly — no framework dependency, no specificity surprises. Verified the rendered HTML now ships all three strips as display:none and the JS un-hides exactly one based on (has_pin, pin_plain).pin_plain column.)/track is now the only customer-facing entry URL. Settings shows a single "Customer Page URL". The old /login route was deleted outright (no redirect shim) — pre-launch, no point carrying it.#noPinStrip, #legacyPinStrip) so each state has its own dedicated UI block. The JS refresh() calls hideAllStrips() then un-hides exactly one based on (hasPin, pin_plain)./login to /track./login entry from app.json public_routes. Visitors to /login get a 404 now — verified locally.if (/login$/.test(path)) auto-expand snippet. The "I have a PIN — sign in" pill is the only PIN-expand affordance./track and /login into one polished entry page. Visitors land on a single screen with a big phone input — type your number to see your orders. A small "I have a PIN — sign in" link expands an optional PIN field below; filling it in signs you straight into the post-login dashboard with messages and balance. Direct visits to the old /login URL still work and pre-expand the PIN field. The visual design borrows the cream/glass-card aesthetic from EstateMax's login: blurred accent + amber blobs, frosted card, plain-English copy ("Check your laundry"), reveal-PIN toggle, and "Secure · 1-tap lookup · Phone + PIN" trust pips./login page, typing a Nigerian or US national number without the + and country code (e.g. 08023456701) made the strict normalize_phone() helper return an empty string, so the page rejected the submission as "Phone and PIN are required" before ever reaching the database. The new sign-in flow does fuzzy phone resolution — same approach the tracking lookup already used — so 08023456701, 2348023456701, +2348023456701, and the last-10-digits variant all match the same stored row.+234 phone numbers, and Lagos addresses (Ikoyi, Ikeja, Lekki). Branch names changed to Ikeja Branch and Lekki Branch.POST /api/portal/sign-in (csrf-exempt, like the rest of /api/portal/*) in [apps/drycleanpro/api/portal.php](api/portal.php). Tries staff first then customers (same precedence as the dedicated /login), fuzzy-resolves the phone across the four standard variants + last-10-digit LIKE match, verifies with verify_pin(), calls app_user_login(), and returns {success, role, redirect} for the client to navigate./api/portal/sign-in and redirects on success; without a PIN it falls through to the existing /api/portal/lookup flow. Auto-pre-expands when the page is reached via the /login URL.requires portal.php. No more separate phone+PIN form; the unified entry is the only entry.customers.pin_plain TEXT (nullable) for the admin-readable copy. Existing installs pick it up on the next request via the lazy migrator. The plaintext column is only returned from customers/get (gated by customers, view permission) and never appears in any list endpoint or audit detail. set_pin writes both pin_hash and pin_plain in lockstep; clear_pin nulls both.+234, entity addresses to Lagos. Also fixed three stale integer user_id values (2) in the orders / payments / status_log rows that pre-dated the May 11 UUID identity pivot; they're now '{{OWNER_UUID}}'.backdrop-filter: blur(2px) even when closed — invisible to clicks but still washing every pixel behind it. The blur is now scoped to the .open state of the backdrop, so the page renders crisp until the sheet opens. Same fix applied to the post-login customer dashboard.app_public_url() so they automatically pick up any verified custom domain mapped to DryCleanPro.confirm() string ("they won't be able to sign in") was prematurely terminating the string and breaking the entire script block, so the Portal Access card never un-hid itself. Switched the quotes; the Set PIN / Reset PIN / Clear controls now appear on every customer profile./track). This is the anonymous "type your phone number, see your orders" page customers reach through share links, QR codes on receipts, and the custom-domain root. It now uses the same polished mobile-first look as the post-login dashboard: a big hero card for the active order (green "Your clothes are ready" variant when something's waiting), a warm amber "You still owe" callout when there's a balance, status pictograms and progress strips on every order card, a chat-style messages section, and a slide-up detail sheet with a full status timeline, items, money breakdown and one-tap WhatsApp share. The phone-input screen got a refresh too — clean card, accent-coloured CTA, "Have a PIN? Sign in instead" link to the customer login. Status copy now reads in plain English ("Your clothes are being washed", "Ready to collect", "On the way to you"). Same API contract (/api/portal/lookup) — no changes needed on the back end.POST /api/portal/lookup + POST /api/portal/mark-notes-read contract. Custom-domain landing (/index route) inherits the new design because it routes through the same view./app/drycleanpro/order?id=X) was throwing a JavaScript error and never finished loading — items, totals, payments, pictures and notes all stayed grey. Same root cause was also hitting customer-history loading on the customer profile. Both pages now load cleanly again.apps/drycleanpro/api/audit.php and apps/drycleanpro/api/customers.php — replaced the dangling COALESCE(tm.display_name, 'System') in two SQL queries (residue from the removed tenant_members JOIN) with a LEFT JOIN staff s ON ('staff:' || s.id) = a.user_id plus CASE-based fallback so admin entries label as Admin, staff entries label by name (or "Staff"), and legacy null rows label as System. The SQL error no such column: tm.display_name is gone.apps/drycleanpro/views/order-detail.php and apps/drycleanpro/views/new-order.php — discount_reasons is now always json_encode'd before being inlined into JS. Previously the raw setting value was pasted into the page; an empty/malformed value produced a JavaScript syntax error that aborted the rest of the page's script, leaving every section locked on its skeleton./login, they now land on a polished mobile-first interface with four tabs — Home, Orders, Messages, Account — instead of the plain card list. The Home tab leads with a big hero card for their currently active order (or a green "Your clothes are ready" card when something is waiting for collection), with a live progress strip showing where in the wash flow it is. Balance owed shows up as a warm amber call-out only when there's something unpaid, and the latest unread message peeks just below.app_user_require('drycleanpro')), same mark-notes-read POST contract, same logout form, same customer-by-phone query — only the surface changed.DATA) consumed by a vanilla-JS renderer on the client; no new APIs added, no new schema, no PS SDK calls. Tabs, filter chips, and the order sheet are pure DOM toggles.settings.currency_symbol; the support phone & WhatsApp shortcuts use settings.business_phone (falling back to the entity phone). Share-token invoice links go through app_public_url() so customers on connected custom domains stay on-brand./p/{your-id}/drycleanpro/login. Customers with a PIN you've issued can sign in to see their own orders, balances, and notes — no Pancho account required.staff table (id, name, phone, pin_hash, role, status, entity_id, last_login_at) and customers.pin_hash. Dropped the dormant tenant_members / tenant_invites tables — gone./app/drycleanpro/api/staff/{list,add,update_role,reset_pin,revoke}, all gated by require_auth() (install owner)./login, /logout, /customer in [apps/drycleanpro/views/public/](views/public/), all driven by the new [core/lib/app-auth.php](../../core/lib/app-auth.php) helpers (app_user_login, app_user_current, app_user_require).apps/drycleanpro/api/customer.php (the old Pancho-customer-role API). Customer dashboard now lives at /p/.../customer and is gated by app_user_require('drycleanpro').identity block and the members api-route from app.json. DryCleanPro's identity is wholly app-managed now./account/domains, the Tracking Portal URL in Settings now shows that domain (e.g. https://tracking.acmecleaners.com/track) instead of the platform fallback (mypancho.com/p/.../drycleanpro/track). Falls back to the platform URL if no custom domain is connected. Same change applies anywhere the tracking link is displayed — share buttons, settings, etc./settings directly — the page is admin-only. Same gate applies to PropertyPro, LogisticsRoute, RealtyManager, and CRMDesk._promote_user_demos_if_plan_active($userId) in extensions/billing/api/apps.php runs UPDATE app_installs SET type='premium', status='active', expires_at=NULL, deactivated_at=NULL, purge_after=NULL WHERE user_id = ? AND (type='demo' OR status IN ('demo','expired','cancelled')) AND app_id IN (SELECT id FROM apps WHERE tier='premium') whenever has_active_plan($userId) is true.has_app_access() in core/lib/app-context.php calls the helper before reading the install row when the requested app is tier='premium' — opening the app post-upgrade self-heals.core/views/{desktop,mobile}/marketplace.php calls the helper at the top of the page render — the marketplace tile clears its "Free trial" badge without needing to open the app first.apps/demo endpoint no longer re-seeds when an install row already exists. Previously the has_active_plan short-circuit always ran _seed_app_for_user(), which collided with rows the demo seed had already inserted and surfaced E_APP_SEED_FAILED ("We hit a snag installing X"). The endpoint now checks for an existing app_installs.id first and skips the seed if found, leaving the demo's data intact.apps/activate instead of apps/demo when there's already a demo install for that app — the right endpoint for a no-re-seed flip — gated by client-side DEMO_SUBS.indexOf(appId) !== -1.core/lib/auth.php _enforce_manual_verification_lifecycle(): early-return for users with any active tenant_memberships row. They were vouched for by the inviting tenant; the call-to-verify ceremony (warning at day 14, lockout at day 21) shouldn't apply.core/index.php _dispatch_auth_api('login') redirect block: (1) the last_app_id check now also accepts an active tenant_memberships row as valid access, and (2) when a fresh login has no last_app_id AND the user has exactly one active membership AND zero installs, the redirect deep-links to /app/{thatApp} instead of /.core/views/{desktop,mobile}/marketplace.php: $activeSubs now appends tenant-membership app_ids after the app_installs loop, so the My Apps pill counts tenant apps and tenant tiles render with the active styling.has_app_access() itself to globally accept tenant memberships — the targeted login-redirect fix above is sufficient and avoids touching billing / demo expiry / private-app gates.core/index.php _route_app(): the per-tenant role from tenant_memberships is now lifted into $tenantRole during the membership-fallback resolution and exposed downstream as $userRole (was hard-coded to 'admin'). The router now also honours each route's roles array in app.json — when present, non-platform-admin users whose role isn't in the allowed list get a 403. Platform owners/staff still bypass for support/debugging. This unblocks correct staff/customer nav rendering (the layout reads $appConfig['navigation'][$userRole]) and makes existing roles: ["admin"] declarations on routes like /reports, /audit, /my-orders actually load-bearing.roles: ["admin"] to the /settings route in apps/{drycleanpro,propertypro,logisticsroute,realtymanager,crmdesk}/app.json./join landing for authenticated users: the view now POSTs to /api/v1/join immediately on load and redirects on success (link-preview crawlers stay unauthenticated, so they can't consume codes by accident). Unauthenticated visitors still see the "Create account / Sign in" card.Invalid CSRF token when clicking the join confirmation. The /join view now reads $csrfToken from the standard _route_view() injection and sends it as X-CSRF-Token on the POST.Unknown app action because extensions/billing/api/apps.php's top-level dispatcher was running when other API files (like core/admin/api/join.php) included it for its helper functions. The earlier guard only checked isset($action) && isset($user), but those are populated by _route_api regardless of which API file is the actual dispatch target. Tightened the guard to also require $category === 'apps' so the dispatcher only fires for /api/apps/* routes; helper-only includes leave the function definitions intact and skip the switch entirely./upgrade — they now land directly inside the tenant's app as a staff/customer member.?return= flow: core/views/{desktop,mobile}/signup.php — the OTP-success handler now uses safeReturnUrl() || data.redirect || '/apps' so the post-signup destination respects the ?return=/join?... query param. Previously the IIFE-style override at script init was being clobbered by the inline handler that ran later.core/lib/app-context.php — the require_tenant_member() owner-bootstrap self-heal now requires an active app_installs row before assuming the viewer owns the app. Without this gate, any non-member visiting /app/{appId} would trip the userId === tenantId check and get a wrongly-bootstrapped "owner of their own tenant" tenant_memberships row, which then blocked access via the wrong subscription path.extensions/billing/api/apps.php was being included for its helper functions but its top-level switch ($action) dispatcher was running too, emitting an HTML warning when $action was undefined; added an if (!isset($action) || !isset($user)) return; guard right before the dispatcher. (2) require_tenant_admin() was rejecting the tenant owner because their platform.db.tenant_memberships row had never been seeded; extended require_tenant_member() self-heal so when the viewer is the tenant owner (their UUID matches the tenant_id) it lazily calls _per_user_app_bootstrap_owner() and retries before throwing 403._per_user_app_bootstrap_member() helper in extensions/billing/api/apps.php mirrors _per_user_app_bootstrap_owner() for non-owner joiners. Wired into core/admin/api/join.php (broadcast + phone-invite paths), core/admin/api/invites.php (accept), and the canonical members/decide action./join landing route + view in core/index.php and core/views/join.php resolves invite links. Click required (no auto-POST) to defend against link-preview crawlers.core/views/{desktop,mobile}/{login,signup}.php now honor a ?return= query param (same-origin only) so post-auth flows from /join bounce back correctly.require_tenant_member() in core/lib/app-context.php now self-heals a missing per-app tenant_members row when the platform tenant_memberships row says the caller is an active member. Fixes legacy installs where the owner pre-dates the bootstrap hook.core/lib/tenant-members-api.php and the Settings card UI to a shared partial at core/views/partials/staff-invites-card.php. apps/drycleanpro/api/members.php is now a one-line shim that requires the shared handler. To opt another per-user app into the same surface (PropertyPro, LogisticsRoute, RealtyManager, CRMDesk, etc.): drop the same one-line members.php shim into the app's api/ folder, add a members entry to its app.json api_routes, and require __DIR__ . '/../../../../core/views/partials/staff-invites-card.php' from its settings sections partial after setting $canManageStaff.apps/drycleanpro/views/members.php and the /members route from app.json. The shared handler still exposes index, decide, revoke, and cancel-invite actions for any future power-user surface that wants them./video/drycleanpro. Multi-video apps get a playlist alongside the player./p/0/… — those links now resolve correctly. The "Copy tracking portal link" in Settings is also fixed (used to build /p//drycleanpro/track because of an undefined variable).customer_attachments table + apps/drycleanpro/api/customer-attachments.php (upload / list / update / delete) mirrored on order_attachments. Files land in uploads/drycleanpro/customers/{customer_id}/.apps/drycleanpro/api/audit.php and apps/drycleanpro/views/audit.php. The audit list joins audit_log.user_id to tenant_members.pancho_user_id for display_name. Accepts ?customer_id=X to narrow to a single customer's history.customers/activity endpoint added for the per-customer timeline (substring-matches audit_log.details against customer name + ticket_numbers, same technique PropertyPro uses).seed_with_currency() {{OWNER_UUID}} placeholder still covers demo seeds./my-orders view for authenticated customers and /members admin view (admin-only) for managing staff + customers.users table with tenant_members(pancho_user_id, role, ...) anchored to the platform Pancho UUID.created_by_user_id, changed_by_user_id, recorded_by_user_id, uploaded_by_user_id, cancelled_by_user_id, customer_notes.created_by_user_id) switched from INTEGER to TEXT UUID.customers.pancho_user_id TEXT UNIQUE (nullable) — walk-in customers keep working; when they sign into Pancho with the matching phone the record auto-links.tenant_invites table for pre-registering staff/customers by phone.