Changelog — DrycleanPro
All notable changes to this app are recorded here. Newest entries on top.
[2026-05-22]
- Customers can install your portal as an app. On a phone, a customer can add your shop's portal to their home screen and open it full-screen like a native app — branded with your business name and colour. On Android the browser offers to install it; on iPhone it's "Add to Home Screen" in the share menu. Works from the sign-in/track page and the customer dashboard.
- Fixed: receipts, invoices, and the customer portal showed dollar signs. On any shop that hadn't explicitly set a currency symbol, printed receipts, the public invoice, the customer tracking/portal page, and the place-order page were all showing amounts in dollars. They now show the Naira sign (₦) by default. You can change the symbol any time in Settings → Currency Symbol and it applies everywhere.
- Fixed: adding a customer whose phone is already on file now shows a clear message instead of an error. If you try to add (or edit) a customer using a phone number that already belongs to another customer — including one filed under a different branch — DrycleanPro now tells you plainly ("A customer with this phone number already exists…") instead of failing with a technical error. Phone numbers are shared across all your branches because customers sign in to their portal by phone.
Internal
- Currency:
drycleanpro_format_money() fell back to format_price($amount, get_app_currency()) when currency_symbol was unset — but get_app_currency() returns a symbol and format_price() expects a code, so it silently printed $ on every install without the setting (13 of 14 live installs). Now defaults to ₦. Same always-false get_app_currency() === 'ngn' fallback removed from views/public/{customer,invoice,portal,place-order}.php, views/new-order.php, and views/partials/settings-sections.php (all default to ₦; the currency_symbol setting still wins). Admin amounts via PS.formatPrice benefit from the core change that honors APP_CONFIG.currencySymbol.
api/customers.php create/update: duplicate-phone check is now global (phone is UNIQUE app-wide) rather than per-entity, and the INSERT/UPDATE are wrapped in a try/catch returning a friendly 409 — closes the raw "UNIQUE constraint failed: customers.phone" 500 when the same phone existed under another branch.
- Schema: dropped the redundant
ALTER TABLE customers ADD COLUMN pin_plain/self_registered and ALTER TABLE orders ADD COLUMN source lines — those columns are already declared in the CREATE TABLE blocks, so the literal ALTERs only re-added them and tripped fresh-install schema load. The lazy migrator still backfills existing installs from the CREATE TABLE definitions.
[2026-05-23]
- Photo/document upload on orders is more reliable. If an upload stalls on a weak connection it now times out after 90 seconds with a clear "check your connection and try again" message instead of the button spinning forever. Oversized files are caught instantly on your device — pick a photo bigger than 10 MB and you get an immediate "try a smaller photo" note rather than a long wait that quietly fails. Server errors during upload now show a readable message too.
- Deleting an expense is now safe for your books. Removing an expense no longer wipes the record from the database — it's voided instead: it vanishes from your expense list and stops counting toward your Profit & Loss immediately, but a copy is kept behind the scenes for your records (and can be restored by support if you ever need it). So a delete can never silently corrupt your accounting history. The confirmation message now explains this in plain words. Also fixed: the Remove this expense button now reliably appears in the edit sheet for anyone with delete permission (it was tied to a role check that didn't always resolve on the owner's own dashboard).
[2026-05-20]
- Existing installs: one-click "set up the standard 9 categories" for Expenses. When you turn on the Expenses feature for the first time, DrycleanPro now automatically seeds the 9 standard categories (Rent & Premises, Utilities, Supplies, Staff, Maintenance, Transport, Marketing, Bank & Fees, Other) along with their ~30 starter items — so you can record your very first expense within seconds of enabling the feature, without typing anything in. If the seed needs to be re-run later (or you set up the feature manually before this update), open Expenses → Manage categories and tap "Set up the standard 9 categories" in the empty state. Safe: the seed refuses to run if any expense categories already exist for the active branch, so it can't overwrite your customizations.
- Demo seed: realistic P&L data out of the box. The marketplace demo install now includes 12 additional completed-and-paid orders spread across the last 30 days (in addition to the 8 in-progress orders that were already there), plus 10 expense entries rescaled to the demo's price economy. A freshly-seeded demo store now shows ~₦66 income, ~₦54 expenses, ~₦12 net profit / ~18% margin on the main Ikeja branch in the Profit & Loss panel — and the Daily Sales line chart spans 10 days across the month instead of a 4-day cluster. Existing installs are not affected (demo seed only runs on demo provisioning).
[2026-05-19]
- New: Expenses + Profit & Loss module. A toggleable module for tracking every penny that leaves your business — rent, electricity, detergent, salaries, fuel, POS fees, anything. Open Settings → Expenses & Profit to switch it on, then a new Expenses tab appears in your menu. Tap the big "Record an expense" button, pick a category (we ship 9 standard ones: Rent & Premises, Utilities, Supplies, Staff, Maintenance, Transport, Marketing, Bank & Fees, Other), pick an item or type your own, type the amount, save. Admins can manage categories + items (with optional suggested amounts that pre-fill the form). Staff can view + record but cannot delete. New branches inherit your expense categories automatically — same auto-clone pattern as the price list. Plain-language tutorial article walks first-time users through it.
- New: Profit & Loss panel in Reports. When the expenses module is on (and the "Show Profit & Loss in Reports" sub-toggle is on), the Reports page now opens with a Profit & Loss block at the top: Money in (income) in green, Money out (expenses) in red, Net profit as a big bold number (green when positive, red when negative), and a Profit margin percentage. One-tap pills switch between Today / Week / Month / Year. Tap "Show breakdown" to see which expense category ate the most money — with a small horizontal bar showing each category's share of the total spend. No graphs, no jargon — designed for owners who just want to know "did I keep anything this month?".
- Fixed: empty Daily Sales chart on Reports. When a shop had no payments yet (or the current date range had none), the Daily Sales panel was rendering as a blank Chart.js canvas — looked broken even though the API was correctly returning an empty array. Now it shows a clear empty state: a line-chart icon, "No sales recorded in this period", a helpful explanation, and a "Show last 90 days" button that widens the range automatically so any historic payments are surfaced.
- Date-range preset pills on Reports. Above the From/To date pickers there's now a row of one-tap pills: Today / This Week / This Month (default) / This Year. Tap one and both date inputs update plus all sections refresh. Much friendlier than manually typing dates for non-technical owners. Existing manual From/To pickers still work for any custom range.
[2026-05-17]
- "View as customer" — impersonate a customer in one tap. New entry in a customer profile's More menu: tap View as customer and a new browser tab opens showing exactly what that customer sees — their dashboard, orders, messages, ready-for-pickup notifications, everything. Useful for support ("walk me through your screen"), QA-ing the customer experience, and spot-checking a self-service order without juggling a test phone. A sticky purple banner across the top of the customer dashboard says "Viewing as Adaeze Okafor" with a Stop button — one tap and the admin is bounced back to that customer's profile. Mirrors the platform-admin impersonation feature you already use at /admin/users, scoped to the app's customer namespace so the admin's main Pancho session in the original tab is preserved. Every start + stop is audit-logged.
- Fixed: WhatsApp button opened with "Couldn't look up the number" error. After the earlier regex fix, the WhatsApp link reached the correct digits but WhatsApp still rejected numbers that were stored in local format (like
08012345678) because wa.me requires the country code. New waPhone() helper auto-promotes Nigerian local numbers (leading 0 + 10 digits → 234 + 10 digits), so a business phone saved either way (+2348012345678, 08012345678, 0801 234 5678, etc.) opens correctly in WhatsApp. Applied to all three WhatsApp buttons on the customer dashboard.
- Fixed: WhatsApp button on the customer dashboard was broken. The regex that strips non-digit characters from the support phone number was double-escaped (
/[^\\d]/g) so it actually stripped out every digit — the resulting wa.me/ link had no phone number, and tapping it did nothing useful. Same bug existed in three places: the Home tab "WhatsApp" button next to Call, the Account tab "Chat on WhatsApp" row, and the order detail "I have a problem with this order" button. All three now correctly preserve the digits and open the right WhatsApp conversation.
- Full customer details visible at the top of every order. Opening an order on the admin/staff side used to show just the customer's name + phone in tiny text. Now the header has a proper details block: tappable phone (one-tap dial), WhatsApp deep-link button, and a prominent pickup-address card with an "open in Maps" arrow so the driver can navigate in one tap. The address is labeled "PICKUP ADDRESS" on customer-self-serve orders so staff can tell at a glance where to dispatch the truck, and "ADDRESS" on regular admin-entered orders. Same treatment for both types — pickup orders just get a small extra "Customer Pickup" chip next to the status pill so it's clear who placed them.
- Fixed: visible seam between the "What's in your bag" drawer and the dock on mobile. The drawer's flat bottom edge was sitting against the dock's rounded top corners (22px radius), so on each side of the join you could see a small wedge of page background bleeding through. Now when the drawer opens, the dock's top corners flatten (animated, 180ms) so the two elements visually merge into one continuous dark panel; on close they round back out. Same fix when the drawer auto-closes after the cart empties or after an order is placed.
- Fixed: customer-facing JS broke on
/track when "Require PIN" was on. The new require-PIN toggle hides the "I have a PIN" expand button when active — but the JS wire-up at the bottom of /track was still trying to bind a click listener to that button. Null reference threw before subsequent listeners (reset button, sheet close, escape-to-close) could be bound, so any place-pickup button or downstream interaction on /track stopped responding to clicks. Switched to optional chaining (?.addEventListener) so it skips cleanly when the element isn't rendered.
- Settings toggle: require a PIN to look up orders. New toggle under Settings → Customer Tracking Page → "Require PIN to look up orders". When OFF (default),
/track works as it does today — phone alone gives anonymous order lookup, PIN is optional for portal sign-in. When ON: PIN field is shown by default (no "I have a PIN" expand pill), PIN is required, submit button reads "Sign in", and the server-side /api/portal/lookup API refuses phone-only requests with a clear "PIN required" error. Useful when you've issued PINs to all customers and want signing-in to be the only path to order info — no more public phone-lookup. Customers who don't have a PIN yet should contact the business to get one set up.
- "Place pickup order" CTA also on the public tracking page. Two more placements added on
/track (the page customers visit BEFORE signing in):
- Entry view (already had this) — "Sign up & book a pickup" button under the phone form. Unchanged.
- Lookup results view (new) — when a customer types their phone (no PIN) and lands on their orders list, the bottom of that list now has a prominent "Place a new pickup order →" button with the tiny footnote "You'll be asked to sign in or set up a PIN first." Tap goes to
/signup; the signup form recognises a known phone and redirects existing customers to sign in at /track so nobody duplicate-registers. Both gated by the Settings → Customer Self-Signup toggle.
- "Place pickup order" CTAs sprinkled across the customer dashboard. One CTA on the Home tab wasn't enough — customers visit the dashboard for different reasons (track a specific order, check messages, look at history), and the new-order ask should be where they are. Now there's a CTA in four places, all gated by the same Settings → Customer Self-Signup toggle:
- Home tab — the original accent card under the stats grid.
- Empty state on Home (no orders yet) — converts the "✨ No orders yet" placeholder into a full-bleed accent hero: "Place your first pickup order — Pick what to wash. {business} comes to you, picks it up, brings it back clean."
- Orders tab — a pinned banner at the top of the orders list ("Place a new pickup order"), and the "All caught up" empty-state card now ends with a "Place a pickup order" button.
- Order detail sheet — at the bottom of every individual order view, a "Place another pickup order" CTA. Highest-intent moment: a customer reviewing an order they're happy with is the most likely to want another.
All four disappear instantly when admin flips the self-signup toggle off.
- New "Picked Up" status — the driver's confirmation that they collected the clothes. Sits between "Pickup Pending" and "Brought In" in the order flow. After a driver physically collects clothes from the customer's address, they (or admin) flip the order to Picked Up so the customer sees "Driver has picked up your clothes" instead of "A driver is coming". Once the clothes hit the shop, status moves to "Brought In" as before. To avoid two statuses called "Picked Up" (the existing final-stage status was also called that), the old completed label has been renamed to Collected — that's the stage where the customer has their clean clothes back. New customer progress bar (for self-serve pickup orders) now reads: Pickup Pending → Picked Up → Brought In → Washing → Ready → Collected. Walk-in orders still show the original 4-stage bar. Status auto-included in app.json + label tables, so new installs already have it.
- "Place new pickup order" CTA on the customer dashboard. Returning customers used to have to navigate to
/place-order manually (or open the link admin shared). Now their dashboard at /customer has an accent-colored card front-and-centre: "Place a new pickup order — More clothes to wash? We'll come pick them up." The CTA only shows when Settings → Customer Self-Signup → Enable self-signup is on — when admin turns the feature off, the CTA, the /signup form, the /place-order page, and the create-pickup API all refuse together, consistently.
- Settings toggle: hide prices on the customer order page. New toggle under Settings → Customer Self-Signup → "Show prices on customer order page". When ON (default), customers see prices everywhere — current behavior. When OFF, prices are stripped from the public customer order flow: item rows show "Tap to add" instead of a unit price, the dock shows "5 items" instead of a running money total, the cart drawer shows quantities only, the pickup details card shows "5 items in your pickup · Pickup fee FREE · You'll see the total when we confirm your order at {business}", and the post-order recap shows quantities without per-item or grand totals. Pricing still applies normally — orders carry the real prices server-side, receipts and admin views are unaffected. This is purely cosmetic on the customer flow so competitors can't price-compare through public links.
- Fixed: items drawer stayed floating on screen after placing an order. If you opened the "N items" drawer (by tapping the dock pill) and then placed the order, the drawer hung around on top of the success screen since it's a
position:fixed element with no auto-dismiss. Now the submit handler explicitly closes it before swapping in the confirmation view.
- Tapping the "N items" pill in the dock now opens a compact items drawer. Before, tapping the pill did nothing visible (it ran a scroll-into-view, but on tall screens the target was already on screen). Now it opens a slim panel that slides up from the dock — listing exactly what you've added, with quantity, "X each" prices, and per-item subtotals. Tap the pill again (or the X) to close. Stays compact (max ~55% of viewport) so it doesn't take over the screen the way the old full-page sheet did. The chevron in the pill rotates to indicate open/closed.
- No more "Review & confirm" intermediate sheet — order placement is one page now. The slide-up sheet that appeared on tap of "Review & confirm" looked cramped on desktop (only covered half the screen) and added an unnecessary step. Now pickup address, notes, and the totals breakdown sit as an inline card right under the items list — once you've added at least one item, it appears automatically. The dock CTA goes straight from "Place pickup order" to the success screen. One screen, one tap.
- Cleaner "1×" qty chip on the success-screen order recap. The little "1×" before each item used to inherit a global pill style (filled accent background) but with muted-grey text — unreadable. Renamed it to a dedicated
.recap-qty class so it gets its own neutral pale-cream chip with mono-style numbers — clean and quietly legible.
- Fixed: "Review & confirm pickup" button on the customer place-order page did nothing. The slide-up confirmation sheet has
display: none in its CSS class, but the click handler was clearing the inline style instead of overriding the class — the element fell back to the hidden rule and never appeared. Now it explicitly sets display: block to override, and the sheet slides up as intended.
- Cleaner Orders list — type and status badges no longer collide visually. The "Customer Pickup" indicator (which marks the type of order) used to sit right beside the status pill ("Pickup Pending", "Brought In", etc.) on the order row — same shape, same size, same row. At a glance you couldn't tell which one was which. Now the status pill stays on the title row (filled, colored pill) and the Customer Pickup chip moves to the meta row below (outlined, smaller, with a truck icon). Same info, much easier to read.
- Renamed "Pickup Coming" → "Pickup Pending". Clearer terminology for the self-serve pickup status.
- Polished post-order confirmation screen (especially on mobile). Two-card stack: a hero card up top with the check mark, "We're on the way" title and order ticket; a separate "What happens next" card with numbered steps; an order recap card showing exactly what you bought and the total. Editorial typography (Bricolage Grotesque + Hanken Grotesk + mono numbers) matching the new place-order aesthetic — feels like a real app, not a stock confirmation page.
- Pickup order page is shorter, the post-order screen is honest, and there's a new "Pickup Pending" status. Three connected improvements to the customer self-serve flow:
- Collapsible category menu. The order page used to dump every category and every item in one long scroll. Now categories are accordions — first one open by default, tap any header to expand. Each category header shows a little pill with how many items you've grabbed from inside.
- Honest confirmation screen. After placing the order, customers no longer get dropped onto their dashboard reading "We got your clothes" (which was a lie before the driver actually arrived). Instead they see a clean "Pickup scheduled. A driver is coming." screen with their order number, a "what happens next" timeline, and a "Track this order" button when they're ready to navigate to their account.
- New "Pickup Coming" status. Self-serve pickup orders start in a dedicated
pickup_pending state — visible on the admin Orders list with the same Customer Pickup badge AND a "Pickup Coming" status pill, so staff know to dispatch. Admin flips it to "Brought In" once they actually collect the clothes. The customer dashboard reflects this truthfully — the progress bar shows a fifth stage at the front ("A driver is coming to pick up your clothes") for self-serve orders only; walk-in orders still show the original 4-stage bar.
- Sign-up button on the customer tracking page. The
/track page (the customer-facing "Check your laundry" landing) now shows a "Sign up & book a pickup" button at the bottom — so customers who arrive there can discover the self-signup flow without needing the direct /signup URL. The button only appears when self-signup is enabled in Settings → Customer Self-Signup (turn off that toggle and the button disappears).
- Customers can now sign themselves up and place a pickup order on their own. New "Customer Self-Signup" card in Settings shows a shareable URL like
mypancho.com/p/{your_id}/drycleanpro/signup (or {customdomain}/signup). Customers tap the link, enter name + phone + pickup address + a PIN they pick, and immediately land on a two-tap order screen: pick items from your price list → confirm. The order shows up in your regular Orders list with a green Customer Pickup badge so you know to dispatch the truck. Push notifications fire instantly on both the signup AND the order so admin/staff phones get an ntfy ping the moment a customer joins or places a request. Three new settings: an Enable toggle, a Pickup Fee field (defaults to ₦0 — Nigerian convention), and a default-branch picker for new self-registered customers.
- Fixed: currency symbol on receipts and prices was stuck on
$ even after switching to ₦. The receipt template was passing the literal symbol (₦) to a helper that expected a currency code (ngn / usd), so it never matched and defaulted to $. Now there's one source of truth — the symbol from Settings → Currency Symbol flows through every PHP page (receipts, notifications, invoices) AND every JS page (order detail, customer dashboard, tracking portal, new-order form). Set it once in Settings; everywhere updates. If you leave the field blank, the symbol is derived from your install's locked currency code (₦ for Naira accounts, $ for USD accounts).
- Tutorials rewritten and expanded. Every tutorial article was rewritten to be specific to DryCleanPro (no more generic "this app updates over time" filler) and to cover every feature added in the past couple of days — the dedicated
/staff sign-in URL, the one-button customer + branch delete, the auto-copied price list when you add a branch, the Currency Symbol override with ₦ default, the unified admin dashboard for staff, and more. Two new tutorial pages were added: Staff and team (everything about adding/managing your team) and Branches and locations (running multiple shops). Check the Tutorials tab in the marketplace for the full list.
- Delete location now cascades — same as customer delete. Previously deleting a branch was blocked if it had orders, customers, staff, or price categories attached ("Move them to another location first"). Now the Edit Location sheet's "Delete location" button always works, and the confirmation lists exactly what'll be wiped — e.g. "This will permanently remove: 19 customers, 9 price categories, plus every payment, photo, note and notification tied to them." Two safety rails stay: you still can't delete your only branch, and you still can't delete the branch you're currently switched to.
- Delete customer is now one button — works whether they have orders or not. Previously deleting a customer who had orders required archiving them first, then permanently deleting from the archived list. Now there's a single "Delete customer" entry in the More menu. The confirmation tells you exactly what's going — "0 orders, safe to remove" for clean deletes, or "Will also delete 12 orders, payments, photos and notes" when the customer has history. One tap, one confirmation, done.
- Default currency symbol is now ₦ (Naira) for new installs. Fresh + demo installs land with
currency_symbol already set to ₦. Existing installs are unaffected — change yours in Settings → Business Info if you want a different symbol.
- Prominent Staff Sign-in URL card in Settings. The staff link used to be buried in a small grey sentence at the top of the Staff section. Now it's a dedicated card with a labeled input + Copy button, matching the Customer Tracking Page treatment — easier to find and share.
- Fixed: Delete location button did nothing. Clicking "Delete location" in the Edit Location sheet was silently ignored — the inline
onclick handler was injecting a JSON-encoded name with quotes that broke the HTML attribute parser. Switched to a proper event listener so the click registers every time.
- Dedicated staff sign-in page at
/staff. Staff used to share the /track URL with customers — same form, but the page reads "Check your laundry," which was confusing when staff landed there. New URL /p/{owner_id}/drycleanpro/staff (or {customdomain}/staff) is a staff-flavoured sign-in: "Staff Access — Sign in to your dashboard." Phone + PIN goes straight to the admin dashboard at /admin/. The Settings → Staff share link now points at /staff automatically. Customers who type their tracking phone here get a friendly redirect message pointing them to /track.
- New branches inherit the price list automatically. When you open a new location, its price list is auto-seeded from your first branch (categories + items, prices and all) instead of starting empty. You'll see a confirmation toast like "Branch added · 104 prices copied from your first branch." Edit the new branch's prices independently afterwards — the clone is one-time at creation. No effect on existing branches.
- Staff now use the same admin dashboard as the owner. When a staff member signs in with their phone + PIN, they land on the full admin interface — same layout, same screens, same workflow the install owner sees. Staff get Dashboard, Orders, Customers, and create-new-order; senior staff (role set to "admin" in the Staff list) also get Settings and Reports. The staff admin lives at
/p/{owner_id}/drycleanpro/admin/ on mypancho.com, or simply /admin/ on a verified custom domain — so staff signing in on either domain stay there. Customers (with portal PIN access) still land on the customer dashboard as before. Sign-out from the sidebar or bottom-nav takes them straight back to the staff sign-in screen.
[2026-05-16]
- Currency Symbol override in Settings. New Currency Symbol field under Settings → Business Info. Type the symbol you want to see on receipts, notifications, and money displays (₦, £, €, $, kr, etc.). Leave blank to use the default for your install's currency. Notification messages ("Payment of …", "Outstanding balance …", "New order for …") now respect this override.
- Delete customer (no orphans). Customers with zero orders on file now get a one-tap Delete customer option in the More menu — no archive-first step required. Existing cascade-delete (for customers with orders) still requires archiving first, as before. Either path now also wipes customer notes, attachments, and notification log entries so nothing is left orphaned in the database. _(Superseded by the unified one-button delete on 2026-05-17 above.)_
- Manage locations from the mobile location switcher. Tapping your branch name in the mobile header now shows a "Manage locations" link at the bottom of the switcher sheet — takes you straight to the page where you can edit or delete branches.
- Edit and delete locations from the Locations page. Each location card now has a pencil button that opens an Edit sheet — change the name, address, phone, and email. The sheet also has a Delete button for removing a location you no longer need. The currently-active location can't be deleted (switch off it first), and you can't delete your only remaining location. _(The "blocked when data attached" gating was lifted on 2026-05-17 above — delete now cascades.)_
- Email field on locations. The Add and Edit sheets now have an Email field so you can record a contact email per branch.
- Fixed "Add staff" not submitting. The Add-staff form was opening but clicks on the form fields and "Add staff" button were silently swallowed. The form is now fully interactive.
- Re-adding a revoked staff member with the same phone now works. Previously the database's unique-phone rule would block it with an opaque error; now the original record is revived with the new name, role, and PIN.
[2026-05-15]
Internal
- Fixed the per-app uninstall hook signature (
int $userId → string $userId) so order-attachment cleanup actually fires when an admin or cron purges the install.
[2026-05-14]
- Added a Watch/Read tab on the tutorial page — every lesson now has a written version alongside the video for users who prefer to read.
[2026-05-13]
- Phone numbers display in local format now. Nigerian numbers (
+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….
Internal
- Added
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.
- Added
PS.formatPhoneDisplay(phone) in [core/assets/js/app.js](../../core/assets/js/app.js) (same regex, mirror of the PHP helper).
- For pages that don't load the PS SDK (public
portal.php and customer.php), added a small inline phoneFmt() copy next to the existing esc() / money() helpers in the same <script> block.
- Applied at every text-render site in drycleanpro:
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).
[2026-05-13]
- Fixed: order detail page got stuck on skeletons forever. A leftover stray line —
</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.
Internal
- [apps/drycleanpro/views/order-detail.php#L450](views/order-detail.php#L450): removed the orphan
</p>\ : ''} between the payment date <p>` and the reference-note conditional.
- Same one-line removal applied to
apps/{logisticsroute,propertypro,realtymanager}/views/order-detail.php.
[2026-05-13]
- Fixed: all three Portal Access state strips were rendering at once. The card had two contradictory messages above the PIN row — "No PIN set yet" and "Current PIN: 8257" and "PIN was set before…" all visible at the same time. Root cause: each strip carried
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).
[2026-05-13] (earlier today)
- Portal Access card on the customer profile is unambiguous now. Three explicit states instead of always-show-the-amber-strip:
- No PIN yet — grey dashed pill "No PIN set yet — the customer can still track orders by phone, but can't sign in for messages or balance." No Hide / Copy buttons, no dots. Only the Set PIN button.
- PIN with readable copy — amber strip with the actual PIN (Show / Copy controls), plus Reset PIN and Clear.
- PIN set before this feature — grey dashed pill "PIN is active but was set before we started keeping the readable copy. Tap Reset PIN to issue a new one you can see and share." (Legacy data only; covers customers whose PIN predates the May 12
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.
Internal
- [views/customer-profile.php](views/customer-profile.php) — Portal Access card grew two new strips (
#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).
- [views/partials/settings-sections.php](views/partials/settings-sections.php) — collapsed the two URL fields into one Customer Page URL under the Customer Tracking Page card. The Staff card's hint URL switched from
/login to /track.
- Deleted [apps/drycleanpro/views/public/login.php](views/public/login.php) and removed the
/login entry from app.json public_routes. Visitors to /login get a 404 now — verified locally.
- [views/public/portal.php](views/public/portal.php) — removed the dead
if (/login$/.test(path)) auto-expand snippet. The "I have a PIN — sign in" pill is the only PIN-expand affordance.
[2026-05-12]
- Merged
/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.
- Fixed: customer sign-in failed even when phone + PIN were correct. On the old
/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.
- Admin can now see a customer's current portal PIN. On the customer profile, the Portal Access card now shows the current 4–8-digit PIN in an amber strip with a Show / Hide toggle and a one-tap Copy button. Useful when a customer forgets their PIN — you can read it back instead of resetting. The PIN is cleared automatically when you tap Clear on portal access.
- Seed data is Nigerian by default now. A fresh DryCleanPro demo install gets Nigerian customer names (Adaeze, Tunde, Funke, Emeka, Ngozi…),
+234 phone numbers, and Lagos addresses (Ikoyi, Ikeja, Lekki). Branch names changed to Ikeja Branch and Lekki Branch.
Internal
- New API action
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.
- [views/public/portal.php](views/public/portal.php) — full rewrite. New EstateMax-style cream/glass shell hosts both the entry form and the previously-built results screen + slide-up order sheet. The PIN field is collapsed by default; an "I have a PIN" pill expands it. Submitting with a PIN calls
/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.
- [views/public/login.php](views/public/login.php) now just
requires portal.php. No more separate phone+PIN form; the unified entry is the only entry.
- Schema: added
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.
- [views/customer-profile.php](views/customer-profile.php) — Portal Access card grew a "Current PIN" strip with show/hide and copy buttons. Hidden by default if no PIN is set.
- Seed data: [seeds/demo.sql](seeds/demo.sql) re-localized — phones to
+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}}'.
[2026-05-12]
- Fixed: tracking page rendered with a full-page blur overlay. The slide-up order-sheet's dim backdrop was permanently applying a
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.
- Fixed: missing "Customer Sign-In URL" in Settings. The Customer Tracking Page card under Settings now shows two URLs side by side: the existing Tracking Portal URL (anonymous phone-lookup) and the new Customer Sign-In URL (post-login dashboard for customers you've issued a PIN to). Both resolve through
app_public_url() so they automatically pick up any verified custom domain mapped to DryCleanPro.
- Fixed: "Portal Access — Set PIN" card on the customer profile was hidden. A stray apostrophe inside a JavaScript
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.
[2026-05-12]
- Redesigned the public tracking page (
/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.
Internal
- Rewrote [apps/drycleanpro/views/public/portal.php](views/public/portal.php) end-to-end. Reuses the visual system from [customer.php](views/public/customer.php) (Manrope, accent-colour-driven CSS variables, status glyphs, hero cards). No back-end changes: same
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.
[2026-05-12]
- Fixed: order detail and new-order pages were stuck on skeleton placeholders. After the May 11 identity pivot, the admin order detail page (
/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.
Internal
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.
[2026-05-12]
- Redesigned the customer dashboard. When customers sign in at
/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.
- Tap any order to open a slide-up detail sheet with a friendly status hero, a vertical timeline of the wash → ready → picked-up journey, every item line, customer notes, a clear money breakdown ("Left to pay" or "Fully paid"), and one-tap actions to view the invoice or share it on WhatsApp. The "I have a problem with this order" button opens a pre-filled WhatsApp chat to your business number.
- Messages tab shows every note you've sent the customer as a chat-style feed and auto-marks them as read when they're opened.
- Account tab shows a profile card with their name, phone, total orders, total paid, and member-since month, plus quick call/WhatsApp shortcuts to your shop and a sign-out button.
- The interface honors your Accent Color setting — everything from the hero card to the active-tab highlight picks it up automatically, so it matches your brand. Plain-English status copy ("Your clothes are being washed", "Ready to collect", "On the way to you") layers on top of the configured status labels.
Internal
- Rewrote [apps/drycleanpro/views/public/customer.php](views/public/customer.php) end-to-end. Same route gate (
app_user_require('drycleanpro')), same mark-notes-read POST contract, same logout form, same customer-by-phone query — only the surface changed.
- All data is rendered server-side into a single JSON payload (
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.
- Money formatting uses
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.
- The new design lives in [apps/drycleanpro/sources/customer-interface-design/](sources/customer-interface-design/) for reference; that folder is not loaded at runtime.
[2026-05-11]
- Staff and customer accounts are now managed inside DryCleanPro. Previously, you invited people through Pancho (Pancho-Connect-style), they signed up to mypancho.com, accepted the invite, and then opened DryCleanPro. That flow is gone. From Settings → Staff you can now add your team directly: type their name, phone, role, and a starting PIN. Share the new Staff & Customer Login URL (also shown in Settings) and they sign in there with phone + PIN — no Pancho account, no invite link.
- New customer portal at
/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.
- Removed: the legacy Pancho-connect invite UI (broadcast links, phone invites, approval queue). The "Staff & Invites" card in Settings is replaced by the new in-app Staff card.
Internal
- Schema: added
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.
- New API:
/app/drycleanpro/api/staff/{list,add,update_role,reset_pin,revoke}, all gated by require_auth() (install owner).
- New public routes:
/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).
- Removed
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').
- Removed the
identity block and the members api-route from app.json. DryCleanPro's identity is wholly app-managed now.
[2026-05-09]
- Customer Tracking URL now reflects your custom domain. If you've added a custom domain pointing at DryCleanPro on
/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.
[2026-05-08]
- Fixed: after upgrading to Premium during a free trial, the marketplace no longer keeps showing Free trial and the in-app banner no longer keeps counting down "Demo · 3h 59m remaining" — your trial flips to a regular premium install the moment you pay, both in the app and in the marketplace tile.
- Removed the failure mode where clicking Keep using on a trial card threw "We hit a snag installing DryCleanPro." The button now flips your existing trial install to premium directly (no re-running the seed file, so your demo work survives) and routes you straight into the app.
- Tenant-invited staff and customers now have a much smoother arrival: signing in directly (without the invite link) takes them straight into your app instead of dumping them on a generic Pancho dashboard, the My Apps tab in the marketplace now lists your shop the way it lists their own paid apps, and the verification "we tried to call you" warning is skipped for them entirely (they were vouched for by your invite, so we don't ring them).
- Staff members no longer see Settings in the sidebar and can't reach
/settings directly — the page is admin-only. Same gate applies to PropertyPro, LogisticsRoute, RealtyManager, and CRMDesk.
- Smoother invite-link arrival: when you click an invite link while you're already signed in to Pancho, you skip the confirmation card and join automatically — one fewer click.
Internal
- Demo→Premium auto-flip is now self-healing in three spots, so a missed flip on plan/upgrade can't strand a user with stale demo state:
- New helper
_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.
- Marketplace "Keep using" button (and its mobile twin) routes through
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.
- Tenant-invitee identity reshape (platform-wide):
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.
- Out of scope: refactoring
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.
- Tenant role propagation + route-level role enforcement in
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.
- Added
roles: ["admin"] to the /settings route in apps/{drycleanpro,propertypro,logisticsroute,realtymanager,crmdesk}/app.json.
- Auto-join on the
/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.
- Fixed:
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.
- Fixed: auto-join was returning
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.
- Removed the "Not now" button and the chatty "You're signed in as X. Tap continue to add yourself to this team." copy in favour of a simple "Joining {Business Name} — One moment…" spinner state.
- Fixed: signing up via a broadcast invite link no longer drops the new user on
/upgrade — they now land directly inside the tenant's app as a staff/customer member.
- Signup
?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.
- Self-heal gate tightened:
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.
[2026-05-07]
- Fixed: starting a new order from a customer's profile now uses that customer's pricing tier instead of falling back to Standard.
- Added a Staff & Invites section in Settings (admin only). Open it and you'll see two ready-to-share links — one for staff, one for customers — plus a small "Invite by phone" form for sending an invite to a specific phone number directly. Copy a link, paste it into WhatsApp/SMS, and the person you send it to signs up to Pancho once and drops right into your shop with the right role. A "Rotate link" button under each one lets you invalidate a link that's been shared too widely.
- Invite links work end-to-end: recipients land on a confirmation page, sign in or sign up to Pancho, and are added to your tenant on click — both as a platform membership and in the in-app member list.
- Removed the standalone Members page — its functionality now lives inside the simplified Settings card.
Internal
- Fixed: Staff & Invites card stuck on "Loading…" with an "Unexpected token '<'" JSON parse error. Two root causes — (1)
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.
- New
_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.
- New
/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.
- Refactor: extracted the per-user-app members API to a shared canonical handler at
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.
- Removed
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.
[2026-05-01]
- Tutorials section. Three short walkthrough videos — Quick tour, Managing the day's orders, Pricing & customer tiers — play in a polished full-screen player you can open from the marketplace info card or share directly via
/video/drycleanpro. Multi-video apps get a playlist alongside the player.
[2026-04-29]
- Fixed "Invalid status." error when changing an order's status — the status filter and the Change Status form now accept all configured laundry stages (Received, In Progress, Ready, Out for Delivery, Completed), not just Cancelled.
[2026-04-20]
- Pictures & documents on every customer profile. Staff can now attach photos and PDFs directly to a customer — useful for snapping a picture of someone holding a high-ticket item (tailored suit, wedding dress) so whoever's at the counter at pickup can match them, or for keeping an ID on file. Each attachment is typed (Item photo / Customer photo / ID / Receipt / Damage / Other), optionally captioned, and optionally visible to the customer on their tracking portal.
- Set a customer's pricing tier when you add or edit them. Pricing tier was already on the price list side but the form didn't let you pick it — now every Add / Edit sheet has a Tier selector, and the customer profile shows a Tier pill next to the name.
- Recent Activity timeline on every customer profile. Shows the last 10 things that happened for this customer (order created, status changed, payment recorded, note added, attachment uploaded) with who did it and when. "View full log" links to the app-wide Activity Log filtered to that customer.
- Activity Log page now exists. The "Activity Log" quick action from the home dashboard used to 404; it's a real page now with search, pagination, and action-type filter.
- Fixed broken share links. Every "Share invoice / Copy link / Print receipt" URL was silently losing the tenant id (it tried to cast a Pancho UUID to an integer) and returning
/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).
Internal
- New
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}/.
- New
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.
[2026-04-19]
- Pancho sign-in for staff and repeat customers. Staff now authenticate through Pancho (no separate in-app PIN). A new Members page lets the shop owner invite staff by phone, mint broadcast invite links for bulk onboarding, and review pending join requests — all three methods feed into the same arrival flow. Customers can sign in to Pancho to see their order history across every dry cleaner they use, get push updates, and pay online, without losing the option to just walk in with a ticket.
- Phone-lookup track page still works for walk-in customers who prefer not to sign up — nothing's removed, the Pancho route is an added richer option.
- Added
/my-orders view for authenticated customers and /members admin view (admin-only) for managing staff + customers.
Internal
- Replaced the dormant in-tenant
users table with tenant_members(pancho_user_id, role, ...) anchored to the platform Pancho UUID.
- All audit FK columns (
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.
- Added
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.
- Added per-tenant
tenant_invites table for pre-registering staff/customers by phone.
- New admin member API: invite, broadcast-link, decide, revoke.
- New customer API: my-orders, get, mark-notes-read (replaces the old session-based portal endpoints for authenticated customers).
[2026-04-18]
- Switching pricing mode to "Manual" now auto-populates Level 2-4 with your Standard items (adjusted by the current percentages) the first time you switch. No more retyping the whole price list per level — just edit what you want to change. Existing manual prices are preserved on later switches.
[2026-04-17]
- Fixed home page crash ("no such table: orders") on fresh installs — app now loads correctly for new users
- Updated subscription price to $47/month (USD) and ₦25,000/month (NGN); setup fee is now $497 (USD) and ₦450,000 (NGN)
- Added 4-tier pricing system: Standard, Level 2, Level 3, and Level 4. Each customer can be assigned a pricing level.
- Pricing mode toggle: "Percentage" auto-calculates higher tiers from Standard prices (e.g. +20%, +40%, +60%), "Manual" lets you set each level independently.
- Pricing settings panel with configurable markup percentages per level.
- "Recalculate All Levels" button syncs all non-Standard prices from the Standard tier.
- New orders automatically load prices matching the selected customer's pricing level.
- Customer profiles now show and store their assigned pricing level.
[Baseline] — 2026-04-16
- Existing feature set captured as the baseline. Subsequent changes will be prepended above this entry.