Feature Counsel - How to Make Print-Flow-360 More Useful for Non-Technical Shop Owners
Generated 2026-06-01 by a multi-agent audit (18 features × Profile→Advise, then synthesis; 37 agents). Lens: every recommendation is judged through the eyes of a non-technical print-shop owner (CLAUDE.md §0), and proposes changes to existing features - not new modules. 110 recommendations across 18 features (62 high-priority).
Executive summary
Your platform is feature-rich but leaks its engineering internals to the non-technical shop owners it serves. The audit found the same five problems repeating across nearly every feature: (1) raw developer values shown as labels (enum codes like “ecommerce”/“hybrid”, “Slug”, “Option Key”, “items_sum_r_o_u_n_damount_tax2”, severity words like “Critical/Info”, strategy-class names); (2) controls and pages that look functional but silently do nothing (a pricing calculator that always returns the base price, three dashboard Report buttons that go nowhere, storefront notification toggles with no API, a Stripe invoice path that 404s, fake “$555-123-4567” contact data and fake “Recent Quotes”); (3) defaults that punish the owner (built-in roles seeded with ZERO permissions so new staff see a blank app, guest checkout off so first buyers hit a login wall, the daily action-summary email off, engagement columns hidden); (4) missing loading/empty/error states so a failed fetch reads as “all clear” or “broken”; and (5) no guided setup - no store auto-created at signup, no webhook URL shown, no “test connection”, destructive deletes with no stated consequences. The throughline: owners cannot tell what is broken, what is fake, what is safe to click, or where to finish a task. The highest-leverage work is not new features - it is making the powerful features you already shipped legible, honest, and safe for someone who has never used software like this. Fix the silent lies first (they destroy trust and lose real money), then ship plain-language labels and sensible defaults platform-wide, then invest in guided onboarding and the calculator/payment correctness that make the product genuinely worth paying for.
Cross-cutting themes
Patterns that recur across many features - fixing these once pays off platform-wide.
1. Plain-language labels everywhere - stop showing developer values to shopkeepers
Owners cannot make decisions when the screen speaks in enum codes, strategy-class names, and bookkeeping jargon. The same internal strings (‘ecommerce’, ‘hybrid’, ‘Slug’, ‘Option Key’, ‘Critical’/‘Info’, ‘Manual’, ‘void’, ‘Unposted’, ‘authorizenet’, ‘minor-third’, ‘single-page/accordion’, ‘items_sum_r_o_u_n_damount_tax2’, ‘Material Library’, ‘Client Page Link’) surface unchanged across the catalog, pricing, orders, quotes, invoices, customers, action center, themes, designer and AI builder. A consistent plain-language label map (and reusing the helpers you already have, e.g. statusLabel(), customerTypeBadge()) makes the product read as finished and trustworthy.
Spans: Products & Catalog, Pricing Engine & Calculator, Orders & Print Jobs, Quotes, Invoices, Payments & Accounting, Customers & Activity, Action Center, Storefront Themes & CMS, Storefront Cart & Checkout, Designer Studio, AI Product Builder, Roles & Permissions (Team Access Control)
2. Kill the silent lies - no control or page may look functional while doing nothing or showing fake data
Nothing erodes trust faster than a button that pretends to work. The admin pricing calculator always returns the un-discounted base price (false confidence on real prices), three dashboard Report buttons spin then go nowhere, the storefront notification toggles persist nothing, the Stripe invoice link POSTs to a non-existent route, an email bug silently drops subscribed recipients, automations fire into a void on mismatched status IDs, and the storefront quote pages show a fake US phone number and fake ‘Recent Quotes’. Each of these makes a careful owner ship a wrong price, miss a payment, or apologise for a page they didn’t know existed. Fix or feature-flag-hide every one.
Spans: Pricing Engine & Calculator, Dashboard & Reporting, Notifications & Automation, Invoices, Payments & Accounting, Quotes, PDF / Artwork Pipeline
3. Sensible defaults so nobody has to configure to get a working store
Your CLAUDE.md §0 demands defaults set to what 90% want, but the platform repeatedly ships the opposite. Built-in staff roles are seeded with zero permissions (new hire sees a blank app), no store is created at signup (the whole setup checklist points at a store that doesn’t exist), guest checkout is off (first-time buyers hit a login wall and abandon), the daily Action Center email is off (owners who log in twice a week miss overdue invoices), customer engagement columns are hidden, and the setup checklist counts seeded demo products as the owner’s ‘first product’. Right defaults turn a confusing first session into a working store.
Spans: Roles & Permissions (Team Access Control), Tenant Onboarding & Setup, Storefront Cart & Checkout, Action Center, Customers & Activity
4. Mandatory loading, empty, and error states - a blank or green screen must never mean ‘failed’
Multiple data views swallow errors in console.error and render as empty or ‘all caught up’, which for money and production is dangerous: the dashboard Action Center widget shows a reassuring green check when the fetch failed, invoice/quote/metric widgets show empty tables indistinguishable from ‘no data’, the job detail page renders fully blank on a timed-out session, and roles save/load failures show nothing. Owners on flaky shop wifi need to know whether data is real or unverified, with a one-click retry.
Spans: Dashboard & Reporting, Action Center, Orders & Print Jobs, Roles & Permissions (Team Access Control), Customers & Activity, Designer Studio, PDF / Artwork Pipeline
5. Guided setup and unconfigured-integration guidance - never leave a dead end, always point to the fix
Owners get stranded at the exact moments that cost sales: no payment gateway connected (customers can’t pay and the owner only learns from a complaint), the webhook URL is never shown (paid orders stick on ‘Pending’ forever), there’s no ‘Test connection’ so a fat-fingered Stripe key surfaces at a real customer’s checkout, a new storefront quote arrives with no alert, and the Print File Checker 503s with a raw server error. Every dead end should follow your §0 pattern: explain in plain language what’s missing and link to where to fix it.
Spans: Integrations & Payment Gateways, Storefront Cart & Checkout, Quotes, PDF / Artwork Pipeline, Tenant Onboarding & Setup, B2B / Business Accounts
6. Destructive actions and money actions must state consequences and capture the real record
Your §0 rule on confirmations is violated repeatedly, and the cost is lost work and broken books: product/role/automation deletes and the one-click ‘Remove samples’ wipe configured work with a generic ‘Are you sure?’; refunds don’t state the amount or where the money goes; and ‘Mark as Paid’ flips a status without recording the amount, method, or date - so a disputed cash payment has no proof. Naming exactly what’s lost (or captured) lets a non-accountant owner act confidently.
Spans: Products & Catalog, Roles & Permissions (Team Access Control), Notifications & Automation, Invoices, Payments & Accounting
7. Currency correctness - one store, one currency, from first paint
Hardcoded USD (’$’, Intl ‘en-US’, currency=>‘USD’) leaks into the product-page price flash, the admin product list (two columns showing different symbols), the Top Products chart, the pricing calculator, and the AI builder result - so euro/rupee/pound shops see dollar signs on their own money and distrust every number. Routing all prices through the existing tenant-aware formatter is a small, repeated fix with outsized trust impact.
Spans: Pricing Engine & Calculator, Products & Catalog, Dashboard & Reporting, AI Product Builder
Quick wins (small effort, high client value - do these first)
| # | Feature | Change | Client benefit |
|---|---|---|---|
| 1 | Pricing Engine & Calculator | Fix or hide the admin Pricing Calculator that always returns the base price - The ‘Test your pricing rules’ widget is a stub: calculate() waits 500ms then always returns {final_price: base_price, rules:[]} and even shows a green ‘no rules applied’ panel as if that were the real answer. Wire it to the real ProductPricingCalculator the storefront already uses, or feature-flag it hidden until wired. Also replace its hardcoded ’$’ with the tenant currency formatter. | An owner who set up ‘100+ flyers = $0.08 each’ currently gets false confidence - a calculator that silently confirms the wrong, un-discounted price - and ships a mispriced product. Honest output (or no widget) prevents real pricing mistakes going live. |
| 2 | Dashboard & Reporting | Make the three dashboard Report buttons go somewhere - or remove them - Sales/Customer/Inventory Report buttons spin a fake 1s timer then do nothing (no @click, no route). Point them at existing views (paid invoices, customers, products/stock) or gate the dead ones out entirely. | On the very first screen after login, three official-looking buttons that do nothing read as a broken product. One-click shortcuts to real data turn dead rows into useful navigation. |
| 3 | Quotes | Remove the fake contact data and fake ‘Recent Quotes’ from storefront quote pages - The /quote calculator hardcodes ‘+1 (555) 123-4567’, ‘quotes@printflow360.com’, and a dummy Business Cards/Flyers ‘Recent Quotes’ list; /profile/quotes shows Paid/Void tabs the workflow never produces. Swap in the tenant’s real store settings and the customer’s real quotes (with empty states), and drop the dead tabs. | A customer landing on a page showing a phone number that isn’t the shop’s and prices that aren’t theirs makes the shop look fraudulent. Removing fake data means the owner never apologises for a page they didn’t know existed. |
| 4 | Action Center | Turn the daily Action Center email ON by default and add a sidebar entry with a live count - Default the daily summary ON (07:30) with a one-line dismissible confirmation banner, and add a named ‘Action Center’ sidebar item with the live count badge the header already polls (instead of only an unlabelled bolt icon). | An owner who logs in twice a week currently never discovers the digest (buried two clicks deep, off by default) and misses overdue invoices on the days they’re away. On-by-default plus a labelled sidebar link means what’s on fire reaches them whether or not they log in. |
| 5 | Orders & Print Jobs | Make ‘Jobs Late’ and order/quote status colours reflect reality - ‘Jobs Late’ counts every past-due job with no status filter, so finished jobs inflate it - exclude terminal statuses. Separately, the order/quote lists use hardcoded colour switches that paint custom and ‘on_hold’ statuses red/grey; drive badges from the tenant’s own per-status hex colours (which the detail page already reads) and run labels through statusLabel(). | An owner triaging at 9am sees ‘Jobs Late: 12’ but can’t tell how many still need rescuing; and their carefully colour-coded statuses show wrong colours. Both fixes let them trust the board at a glance. |
| 6 | Products & Catalog | Replace raw severity/type/status labels with shopkeeper words using maps you already have - Add one shared label map and reuse existing helpers: product types (Printing→‘Print Product’, ecommerce→‘Physical Product’, hybrid→‘Print + Physical’), Action Center severities (Critical→‘Urgent’, Info→‘Good to know’ - matching the email), customer ‘Manual’→‘Added by store’ (customerTypeBadge already does this), order ‘void’→‘Cancelled’ (statusLabel already does this). Apply across list badges, dropdowns and detail headers so all surfaces match word-for-word. | Owners scanning their catalog, customer list, or alert feed currently read lowercase database codes and can’t tell a printed banner from a physical mug, or whether ‘Info’ matters. Plain words make every list instantly readable. |
| 7 | Pricing Engine & Calculator | Fix the dollar-sign flash and the two-formatter currency disagreement - formatProductPrice() hardcodes ’$’ and is the pre-calculation fallback, so euro/rupee stores flash ‘$0.00’; the admin product list mixes a tenant-aware formatMoney() with a local USD-hardcoded formatPrice() so two columns show different symbols. Pull the store currency from useThemeSettings() (or render a neutral skeleton) and route every column through the existing tenant-aware formatter. | A euro shop watching its own product page flash a dollar sign and its admin list show ’$’ and ’€’ in the same table reads the app as broken. Consistent currency from first paint earns trust. |
| 8 | Products & Catalog | State consequences on every destructive confirmation and name what’s lost - Product delete, role delete, automation delete, the one-click ‘Remove samples’ bulk delete, and the refund popup all use a generic ‘Are you sure?’. Name the item and spell out the loss (‘permanently delete Premium Business Cards including its sizes, options, pricing rules and images, and remove it from your storefront’; ‘remove access for the 3 team members using this role’; ‘refund $120.00 to the card via Authorize.Net’). Restate the action on the button (‘Delete Product’, ‘Record $X Payment’). | Owners cleaning up their catalog or roles today wipe hours of configured work, lock out working staff mid-shift, or double-refund a customer with one careless click. Stated consequences let them act confidently and stop costly mistakes. |
| 9 | Customers & Activity | Show engagement columns and actionable empty states on the Customers page by default - The seeded default view omits Last Order, Total Spent, Orders, Last Login (the cells already render); add them to the seeded view plus a back-fill for older tenants. Replace ‘(N/A)’ detail placeholders with ‘No email added - click to add’, and self-heal a missing view instead of showing ‘Invalid view’ on a blank table. | An owner opening Customers to find their best repeat customers sees a plain address book with no spend/order data and a cryptic config icon - or an ‘Invalid view’ error that reads as lost data. Surfacing the engagement data they were promised, the moment the page loads, is the whole point of the feature. |
Biggest impact (larger, most worth-paying-for)
| # | Feature | Change | Client benefit |
|---|---|---|---|
| 1 | Tenant Onboarding & Setup | Auto-create the first store at signup and make onboarding a finishable, honest path - No store exists after registration, so the ‘Get your store ready’ checklist points at a store that doesn’t exist - every step silently reports incomplete and links go nowhere. Auto-create one primary store (named after the business) and run StoreOnboardingService::seedAll() at approval, count only non-demo products toward ‘add your first product’, and seed honest neutral defaults instead of fake ‘10,000+ Orders Delivered’/‘4.9 rating’ stats and dead facebook.com social links that would otherwise go live verbatim. | A new owner’s first session today is ‘figure out why the buttons are broken’ on a dashboard whose checklist can never complete, with a risk of publishing fake trust numbers. Instead it becomes ‘add my logo and a product’ on a working, seeded store - the difference between a trial that converts and one that bounces. |
| 2 | Invoices, Payments & Accounting | Make payments actually work end-to-end: connect Stripe correctly, show the webhook URL, add Test Connection, and record real payments - Close the payment trust gaps as a set: wire the Stripe invoice button into the working multi-gateway abstraction (today it POSTs to a non-existent route and dead-ends the customer); show a copy-able per-store webhook URL with a ‘paste this in Stripe or paid orders stay Pending’ helper; add a ‘Test connection / Send test’ button so bad keys surface before a real checkout; block activating a half-configured or sandbox-in-production gateway; warn the owner in admin when NO payment method is connected; and make ‘Mark as Paid’ open a Record Payment dialog (amount, method, date) that creates a real payment row and feeds the accounting totals/CSV (which today exclude all invoice payments). | Every one of these is a place where an owner believes they can take money but silently can’t - a dead Stripe link, a stuck-on-Pending order, a rejected key found only at checkout, a green invoice with no payment record, or an accounting export that omits all cash and cheque. Getting paid reliably and reconciling the books is the core promise of a commerce SaaS; this is what makes it worth paying for. |
| 3 | Pricing Engine & Calculator | Make the pricing engine usable by a shopkeeper: guided goal-first rules, correct defaults, and a real calculator - The pricing engine is built for power users and quietly mis-prices for everyone else. Add a guided ‘What do you want to do?’ picker (bulk discount / rush fee / price by square foot) that pre-loads the right template; relabel strategies and the Adjustment-vs-Replacement choice in business terms (‘Add to the price’ / ‘Set the whole price’) with worked examples and correct per-type defaults (so a bulk tier doesn’t silently double the price); add a simple inline ‘quantity discount’ table on the Pricing tab so common bulk pricing never touches the rules engine; label tier columns ‘Price each’ vs ‘flat price for the batch’ with a sample row; and wire the calculator to the real engine. Mirror admin↔storefront per PRICE_CALCULATOR_SYNC_GUIDELINES. | Pricing is the heart of a print shop and today it’s a minefield: leaving a tier on the default ‘Adjustment’ silently doubles the price, the strategy names don’t map to any goal, and the calculator that should catch the mistake lies. A guided, plain-language pricing flow with correct defaults means owners price right the first time instead of discovering errors from a customer complaint. |
| 4 | Roles & Permissions (Team Access Control) | Pre-fill built-in staff roles with sensible permissions so new hires aren’t locked out - All seven built-in roles are seeded with ZERO permissions, so a new Designer/Sales Rep logs in to a blank nav that reads as a broken app - and the owner gets no prompt that they were supposed to configure the role first. Seed sensible defaults per role (Designer: jobs/tasks editable, quotes visible; Sales Rep: quotes/customers editable, invoices visible; PM: visibility across orders/jobs/customers), keep them editable, tell the owner the new hire gets an email to set their own password, fix the edit-role screen that says ‘Create New Role’ and greys out the name, and stop offering access tiers the backend silently ignores (mark admin-only modules ‘Admin only’). | The first time an owner adds a staff member is a make-or-break moment; today that person is locked out of everything with no clue why, and the owner can’t even correct a typo’d role name or trust that the toggles they set do anything. Working defaults and an honest editor make team access something a non-technical owner can actually use. |
| 5 | B2B / Business Accounts | Make the B2B promise hold across the storefront and let owners onboard companies in one sitting - A logged-in B2B buyer sees full B2C prices on every product card because StorefrontProductResource returns raw base_price - the catalog visibly contradicts ‘your prices are 15% lower’ until they open the configurator. Apply CompanyPricingResolver on the listing path (the cache key already includes :company:{id}), show the negotiated price with the original struck through; let the owner create a brand-new customer directly from the Contacts tab (today search-only dead-ends on anyone who’s never shopped); replace the error-prone signed manual-adjustment field with a Charge/Credit toggle and live plain-language preview; surface a ‘set a store up for business first’ guided path; and clearly mark budget/role fields as informational-today so owners don’t expect un-enforced spend caps. | B2B is a premium, higher-revenue capability, and today it undermines the owner’s own sales pitch (wrong prices on the catalog), forces a clunky ‘go self-register first’ onboarding, and invites backwards ledger entries. Making the negotiated price show everywhere and letting an owner stand up a corporate account during the sales call is what makes B2B genuinely sellable. |
| 6 | PDF / Artwork Pipeline | Close the artwork/PDF quality and print-readiness gaps so jobs don’t fail on press - Customer-uploaded files (up to 200MB, no checks) appear in the order as a bare download link with no quality info - the owner finds a 72-DPI RGB or wrong-size file only after starting production. Surface a print-readiness badge (green ‘Print-ready’ / amber ‘Check before printing’ / red ‘Not print-ready’) driven by the preflight service, with a ‘Quality not checked’ chip when the service is off. Label the designer ‘Download PDF’ by quality (or route it to the 300-DPI print-grade endpoint) so nobody ships a blurry web-grade file as the deliverable, and make the Print File Checker and preview-503 states explain themselves with retry instead of raw server errors. | In a print business, a low-res file caught after plates are made is wasted ink, reprints, and an angry customer. A readiness badge on the order screen and an honestly-labelled print-grade PDF turn ‘discover the problem in production’ into ‘catch it before you start’ - directly protecting the owner’s margin and reputation. |
Per-feature recommendations (full appendix)
- Products & Catalog (6)
- Pricing Engine & Calculator (6)
- Orders & Print Jobs (6)
- Quotes (6)
- Invoices, Payments & Accounting (7)
- Customers & Activity (6)
- B2B / Business Accounts (6)
- Roles & Permissions (Team Access Control) (7)
- Notifications & Automation (6)
- Action Center (6)
- Tenant Onboarding & Setup (6)
- Integrations & Payment Gateways (6)
- Storefront Themes & CMS (6)
- Storefront Cart & Checkout (6)
- Designer Studio (6)
- AI Product Builder (6)
- PDF / Artwork Pipeline (6)
- Dashboard & Reporting (6)
Products & Catalog
- Show human product-type names instead of raw enum values everywhere they appear - 🔴 High · effort S
- Today: The product list (nuxt/app/pages/products/index.vue line 147 table badge and line 365 grid badge) renders row.product_type || row.type verbatim, so a shop owner sees ‘printing’, ‘ecommerce’, ‘tshirt’, ‘hybrid’ across their whole catalog. These are internal database codes, not words a print-shop owner uses. The same internal style also leaks into pricing-rule type badges (e.g. ‘Area Based’, ‘Quantity Based’).
- Change: Add a single shared label map (Printing -> ‘Print Product’, tshirt -> ‘Apparel / T-Shirt’, ecommerce -> ‘Physical Product’, service -> ‘Service’, hybrid -> ‘Print + Physical’) and render it in both the table and grid badges, and reuse the same map in the General tab dropdown labels so the create-time choice and the list badge match word-for-word. The dropdown in General.vue (lines 149-156) already has friendlier-ish labels but they still say ‘Ecommerce’/‘Hybrid’ - align all three surfaces to the plain-language map.
- Why it helps the owner: A store owner scanning their catalog to find ‘that mug I sell’ today reads a column of lowercase developer codes and cannot tell a physical mug (‘ecommerce’) from a printed banner (‘printing’). Instead they would read ‘Physical Product’ vs ‘Print Product’ and instantly know which row is which without guessing what ‘hybrid’ means.
- Make the delete confirmation state exactly what is lost and restate the action on the button - 🔴 High · effort S
- Today: Utils/ConfirmationDialog.vue renders a generic ‘Are you sure you want to delete this product?’ (line 37-39) with a plain ‘Delete’ button. It never tells the owner that the product’s options, sizes, pricing rules, and gallery go with it, that it is removed from the storefront, or that it cannot be undone. This directly violates the CLAUDE.md destructive-action rule.
- Change: Pass the product name into the dialog and render a consequence line: “This will permanently delete ‘Premium Business Cards’, including its sizes, options, pricing rules and images, and remove it from your storefront. This cannot be undone.” Change the confirm button label from ‘Delete’ to ‘Delete Product’. Apply the same to UtilsDeleteEntity used in the table so both paths match.
- Why it helps the owner: A store owner cleaning up their catalog today clicks Delete on a generic ‘Are you sure?’ and may not realize they just wiped every paper-type option and quantity discount they spent an hour configuring. Instead the dialog names the product and spells out what disappears, so they delete confidently and never lose pricing work by accident.
- Replace the one-click ‘Remove samples’ bulk delete with a counted, named confirmation - 🔴 High · effort S
- Today: The demo-products banner (index.vue lines 41-50) fires removeAllDemoProducts on a single click and deletes every sample product via Promise.all (line ~448) with no confirmation and no undo. A misclick on a red button wipes all the pre-built, ready-to-customize sample products at once.
- Change: Gate ‘Remove samples’ behind the same consequence dialog: “Remove all 6 sample products? These pre-configured examples (Business Cards, Banners, …) will be permanently deleted. You can always add your own from scratch.” with a ‘Remove Samples’ confirm button. Keep the count and ideally list the names being removed.
- Why it helps the owner: A new owner exploring the catalog the first day often clicks the prominent red ‘Remove samples’ to tidy up - and today instantly loses the realistic, pre-priced examples that were the easiest way to learn how a product is built. Instead they get one confirmation showing what and how many will go, so they can change their mind before destroying their best learning material.
- Hide the ‘Slug’ and ‘Option Key’ technical identifiers and rename the ‘Gen’ button - 🔴 High · effort M
- Today: General.vue line 11-14 shows a prominent ‘Slug’ field (‘Leave blank to auto-generate’); the option editor (Upsert.vue) shows an ‘Option Key’ field with hint ‘Unique identifier for this option’; and the SKU field has an unexplained ‘Gen’ button (General.vue line 21). ‘Slug’, ‘Option Key’, and ‘Gen’ are developer terms a print-shop owner cannot decode.
- Change: Collapse the Slug field into an ‘Advanced’ disclosure (or remove from the form and auto-generate silently from the name, exposing it only as a read-only ‘Web address’ under SEO). Auto-generate the Option Key silently and remove it from the option form entirely. Rename the SKU button from ‘Gen’ to ‘Generate’ with a tooltip ‘Create a SKU for me’.
- Why it helps the owner: An owner adding their first product today hits a required-looking ‘Slug’ box and a cryptic ‘Option Key’ and either freezes or types garbage into a field that controls their public URLs. Instead the form shows only fields they understand (Product Name, Price, Type), the system handles the web address and keys behind the scenes, and ‘Generate’ clearly offers to make a SKU for them.
- Add a guided ‘Quantity discount’ shortcut so simple bulk pricing doesn’t require the pricing-rules engine - 🟠 Med · effort L
- Today: The most common thing a print shop wants - ‘cheaper per unit when you order more’ - currently means leaving the Pricing tab, opening the dedicated pricing-rules form, and choosing among developer-framed types (Fixed, Percentage, Formula, Area Based, Conditional) plus an ‘Applies To’ selector and a numeric ‘Priority’ field. The mental model is built for power users, not a shop owner who just wants ‘500 cards = $40, 1000 = $70’.
- Change: On the Pricing tab (PricingHub.vue), add a prominent ‘Add a quantity discount’ card that opens a simple inline table: quantity from / to and price (or % off), with an ‘Add tier’ row. Generate the underlying Quantity Based rule from it automatically - hiding priority, Applies To, and formula syntax. Keep the full rules page as an ‘Advanced pricing’ link for the rare complex case.
- Why it helps the owner: A store owner who wants to reward bigger orders today must learn what ‘Area Based’, ‘Conditional’, and rule ‘Priority’ mean just to set a 10%-off-at-1000 tier, and many will give up or price it wrong. Instead they fill a plain quantity-and-price table right where they set the base price, and their bulk discount works without ever seeing a formula or a priority number.
- Let Gallery and Inventory auto-save the draft instead of blocking with a ‘Save the product first’ wall - 🟠 Med · effort M
- Today: Gallery and Inventory tabs show a blocking ‘Save the product to enable gallery uploads / inventory management’ alert when the product is unsaved, with no inline save action. A new owner’s instinct is often to add photos first, so they jump to Gallery, hit a dead-end message, and must navigate back to General, save, then return - all subsequent tabs are also disabled via the opacity-50 + pointer-events-none guard.
- Change: When a user opens a save-gated tab on an unsaved product, either (a) auto-create the draft in the background on first interaction, or (b) replace the blocking alert with an inline ‘Save product to start uploading images’ button that saves the General tab and stays on Gallery. Either way, never leave the owner to figure out that the fix lives on a different tab.
- Why it helps the owner: An owner adding their first product naturally clicks ‘Gallery’ to upload the product photo first - and today hits a wall telling them to save, with no button to do it, so they bounce between tabs confused about why nothing works. Instead the draft saves itself (or one inline button saves it) and they upload the photo right where they expected to.
Pricing Engine & Calculator
- Make the admin Pricing Calculator actually calculate (kill the fake stub or hide it) - 🔴 High · effort M
- Today: The ‘Test your pricing rules’ calculator on the pricing-rules guide page (nuxt/app/components/admin/product/shared/PricingCalculator.vue) looks fully functional - width/height/quantity/base-price inputs and a ‘Calculate Price’ button - but calculate() at lines 177-205 is a stub. It waits 500ms via setTimeout, then always returns { final_price: inputs.base_price, rules: [] }. It never calls the API, so it ignores every tier, area rate, formula, and combination the owner set up, and even shows a reassuring green ‘No rules applied / base price is being used’ panel as if that were the real answer.
- Change: Replace the stub with a real call to the existing storefront calculator. Wire calculate() to POST the product UUID + inputs to an admin-side endpoint backed by the same ProductPricingCalculator::calculate() the storefront uses (app/Services/Pricing/ProductPricingCalculator.php), and render the returned breakdown array (the same line items the customer sees). Until that endpoint is wired, do NOT ship the widget visibly - gate it behind a flag so owners never see a calculator that lies. Also replace the hardcoded ’$’ in its formatPrice() (line 208) with the tenant currency formatter.
- Why it helps the owner: A shop owner who sets up ‘100+ flyers = $0.08 each’ and wants to confirm 250 flyers costs $20 before going live must today either trust a calculator that silently answers with the un-discounted base price (false confidence - they ship a wrong price), or abandon it and go to the live storefront, pick options, and read the price panel. Instead they could type 250 into the calculator on the same page where they built the rule and see the real $20 with the tier line itemized.
- Rename ‘Adjustment vs Replacement’ to a plain-language choice with a worked consequence - 🔴 High · effort M
- Today: On the rule form (pricing-rules/form.vue lines 159-178) the ‘Rule Mode’ radio offers ‘Adjustment’ and ‘Replacement’ with a help tooltip written for developers: ‘Replacement sets the subtotal to a specific value - only the highest-priority replacement applies.’ It never states the business outcome. An owner who builds a Quantity Based tier rule and leaves it on the default ‘Adjustment’ gets the tier price ADDED on top of the base subtotal instead of replacing it - producing a price roughly double what they expect - and no error is raised.
- Change: Relabel the two options in business terms: ‘Add to the price (use for fees, surcharges, discounts)’ and ‘Set the whole price (use for tier/bulk prices and fixed overrides)’. Replace the tooltip with one concrete worked example for the rule type currently selected, e.g. for a Quantity Based rule: ‘Base price $0.10 + your tier $0.08. Add to the price = $0.18/unit. Set the whole price = $0.08/unit. For bulk tiers you almost always want Set the whole price.’ Default Quantity Based and Fixed Price rules to ‘Set the whole price’, and Percentage/Formula/Area to ‘Add to the price’.
- Why it helps the owner: A shop owner offering bulk pricing today must understand that a tier is technically a ‘Replacement’ or their quantity discount stacks on the base and prints double - a mistake invisible until a customer complains. Instead the default would already be correct for a tier, and the choice would read in the language of their actual goal.
- Replace strategy-type developer labels with goal-first labels and add a ‘what do you want to do?’ guided picker - 🔴 High · effort M
- Today: The strategy dropdown (form.vue lines 494-499) lists ‘Fixed Price’, ‘Percentage’, ‘Formula’, ‘Area Based’, ‘Quantity Based’, ‘Conditional’ - named after the engine’s strategy classes, not the owner’s intent. An owner who wants ‘10% off for orders over 50’ has no obvious entry: it could be Percentage, or Quantity Based with tiers. There is no path from the business goal to the right type; they must read the right-hand help panel to reverse-engineer it.
- Change: Relabel the options goal-first: ‘Bulk / quantity discount’ (quantity_based), ‘Percentage markup or discount’ (percentage), ‘Price by size / area, e.g. banners’ (area_based), ‘Fixed price for this option set’ (fixed), ‘Custom formula (advanced)’ (formula), ‘Price for a specific option combination’ (conditional). Above the dropdown, add a short one-line picker - ‘What do you want to do?’ - with the three most common goals (bulk discount, rush fee, price by square foot) as clickable chips that select the matching type AND pre-load the corresponding template from the existing 8 templates.
- Why it helps the owner: A shop owner trying to add a rush-order surcharge today must read each strategy’s help text to learn it is a ‘Percentage’ or ‘Fixed’ rule; instead they click a ‘Rush order fee’ chip and the form arrives pre-shaped for that exact task.
- Fix the dollar-sign flash and the two-formatter currency disagreement so non-USD stores look correct - 🔴 High · effort S
- Today: Two hardcoded-USD bugs hit non-dollar stores. (1) frontstore/app/utils/productHelpers.ts formatProductPrice() (lines 148-151) hardcodes ’$’ + amount.toFixed(2); it is the fallback shown on the product page before the first calculation returns, so a euro or rupee store flashes ‘$0.00’ for a beat - looking broken to the customer. (2) The admin product list (products/index.vue) mixes formatMoney() (tenant-currency-aware) at lines 166/171/175 with a local formatPrice() at line 529 that hardcodes Intl.NumberFormat(‘en-US’,{currency:‘USD’}), so two columns in the same table can show different currency symbols for the same store.
- Change: Make formatProductPrice() take the store currency symbol from useThemeSettings() (the same source ProductPriceSummary already uses correctly) instead of a literal ’$’, or render a neutral skeleton placeholder instead of a fake $0.00 until the real result arrives. In products/index.vue, delete the local USD-hardcoded formatPrice() and route every price column through the existing tenant-aware formatMoney() with the store symbol.
- Why it helps the owner: A shop owner running their store in euros today watches their own product page flash a US dollar sign before the real price loads, and sees their admin product list show ’$’ in one column and ’€’ in another - both read as a broken/untrustworthy app to them and their customers. Instead every price shows their actual currency, consistently, from first paint.
- Label quantity tiers and area units in shop terms and pre-fill one example row - 🟠 Med · effort M
- Today: Quantity tier rows (form.vue lines 263-277) use raw column names ‘From Quantity’, ‘To Quantity’, ‘Unit Price’, ‘Total Price (optional)’ with an empty grid and no example. The crucial Unit-Price (per item) vs Total-Price (flat for the whole range) distinction is buried in a tooltip; picking the wrong column produces a price off by a factor of the quantity with no warning. Separately, the area unit dropdown labels read ‘Square Feet (sq_ft)’ but after selection the field surfaces the internal code ‘sq_ft’ rather than ‘per sq ft’.
- Change: Rename the tier columns to ‘Order at least’, ‘Up to’, ‘Price each’ and ‘Or flat price for the batch (optional)’, and seed the empty tiers grid with one greyed example row (e.g. 1 / 49 / $0.10 / blank) shown as a placeholder so the owner sees the shape. Add an inline note on the row: ‘Fill EITHER Price each OR flat price - not both.’ For area units, drop the internal codes from the visible label entirely (‘Square feet’, ‘Square inches’, …) and render the chosen unit back to the owner as ‘per sq ft’ wherever the rate is echoed.
- Why it helps the owner: A shop owner pricing 500 business cards at 8 cents each today can accidentally type 0.08 into ‘Total Price’ and unknowingly charge 8 cents for the whole batch (or type a batch price into ‘Unit Price’ and charge 500x) with nothing flagging it; instead plain ‘Price each’ vs ‘flat price for the batch’ labels plus a sample row make the right field obvious before they ever save.
- Add guardrails for conflicting rule priority so an ignored rule never fails silently - 🟠 Med · effort M
- Today: Priority is a raw number the owner edits both by drag-reorder on pricing-rules/index.vue AND in a separate Priority field on form.vue - so editing a rule after dragging can silently contradict the dragged order. Worse, two ‘Set the whole price’ (replacement) rules at the same priority resolve to only one winning silently; the other rule the owner carefully built simply never fires, with no warning anywhere.
- Change: Remove the manual Priority number field from the form and make drag-order on the list the single source of truth (the list already writes priority on reorder). On the list page, detect when two or more ‘Set the whole price’ rules could both apply to overlapping conditions and show an inline amber warning on the losing rule: ‘This rule is overridden by [other rule] and will never apply. Reorder or change its mode.’ - matching the integration-warning banner pattern in CLAUDE.md.
- Why it helps the owner: A shop owner who creates both a ‘Premium material override’ and a ‘Bulk tier’ as full-price rules today gets one of them silently ignored and can only discover it by noticing a wrong price on a live order; instead the offending rule carries a plain-language warning telling them exactly which rule wins and how to fix it.
Orders & Print Jobs
- Make the order-list status colours match what the owner actually set - 🔴 High · effort S
- Today: The order list (nuxt/app/components/order/List.vue, getStatusColor at lines 453-468) uses a hardcoded colour switch: ‘open’ renders RED, and any status with no case (including the paused ‘on_hold’ flag and every custom status the owner created) falls through to gray. So a shop owner who deliberately colour-coded ‘Open’ as blue in their status settings, and who set up their own statuses, sees the list paint them red or grey instead. The list silently contradicts the detail page, which correctly reads the tenant’s hex colours from the status API.
- Change: Delete the hardcoded getStatusColor switch and drive the list badge from the same per-status hex colour the detail page already pulls from GET /order-statuses (apply it via inline style on the badge, with a sensible neutral fallback only when a status has no colour set). Keep the human label coming from statusLabel(). Add a distinct treatment for ‘on_hold’ (e.g. amber) so a paused order is visually different from a cancelled one.
- Why it helps the owner: A store owner who set ‘Open’ to blue and named a custom ‘Awaiting Artwork’ status in pink expects the order list to show those exact colours, but today the list shows red and grey and looks broken; instead the list would mirror their own colour scheme everywhere, so they can scan the board and trust the colours mean what they decided.
- Stop the ‘Jobs Late’ KPI from counting finished jobs - 🔴 High · effort S
- Today: In app/Http/Controllers/Api/Job/PrintJobController.php (getKpiDetails, line 282) the ‘Jobs Late’ count is PrintJob::whereDate(‘due_date’,’<‘,now())->count() with no status filter at all. A job that was delivered last month but had a past due date still adds to ‘Jobs Late’. The number a shop owner glances at to decide what is on fire is inflated by work that is already done.
- Change: Exclude terminal statuses (Fulfilled/Completed/Delivered and Cancelled, resolved by JobStatus id the same way the In-Progress KPI already resolves completed status ids) from the jobsLate query, so it counts only past-due jobs that are still open. Apply the identical fix anywhere ‘Jobs Late’ is computed for the calendar/Kanban badge.
- Why it helps the owner: A shop owner who opens /jobs in the morning and sees ‘Jobs Late: 12’ today can’t tell how many actually still need rescuing versus how many are old finished jobs, so the number is useless for triage; instead ‘Jobs Late’ would show only genuinely-late open jobs, so the owner knows exactly how many fires to put out before lunch.
- Give the print-job detail page a real error state instead of a blank screen - 🔴 High · effort S
- Today: In nuxt/app/components/job/view.vue the data-fetch catch block (lines 800-802) only does console.error and leaves the page empty. If a shop owner’s session expired or the network hiccupped, the job page renders completely blank: no message, no retry, nothing to click. This also violates the mandatory loading/empty/error rule in CLAUDE.md section 0.
- Change: Add an error state to the job detail page mirroring the order detail page: on fetch failure set an error flag, show a plain-language panel (‘We couldn’t load this job. Check your connection and try again.’) with a Retry button that re-runs the fetch, and a ‘Back to Jobs’ link. Keep the existing skeleton for the loading phase.
- Why it helps the owner: A shop owner whose login timed out while checking a job today just sees a white screen and assumes the job was deleted or the app is broken; instead they would see a clear message and a Retry button, so they recover in one click instead of calling support or re-creating the job.
- Hide the raw database alias from the order list’s Total column settings and filters - 🟠 Med · effort S
- Today: The grand-total column uses the key ‘items_sum_r_o_u_n_damount_tax2’ (List.vue lines 292, 295, 539). The displayed cell label is ‘Total’, but the moment a shop owner opens the column-visibility/Views config or a sort/filter control, this raw aggregate alias leaks into the UI as the field identifier - exactly the kind of internal token CLAUDE.md section 0 forbids.
- Change: Decouple the human-facing column from the storage key: keep the API field name internally but give the column a stable, plain key/label of ‘total’ / ‘Total’ wherever it surfaces in the column picker, Views modal, and sort/filter UI. Map back to the raw aggregate only at the data-read layer so nothing user-visible ever shows ‘items_sum_r_o_u_n_damount_tax2’.
- Why it helps the owner: A store owner customising which columns appear, or sorting by order value, currently sees a garbled developer string and second-guesses whether they’re touching the right field; instead they’d only ever see ‘Total’, so configuring their order view feels safe and finished.
- Run the order detail status badge through the plain-language label map - 🟠 Med · effort S
- Today: The status stepper header on the order detail page renders the raw DB status (e.g. {{ order.status }}) directly. A tenant whose status key is ‘void’ sees the word ‘void’ in the header badge, even though statusColor.ts already maps void -> ‘Cancelled’ and the order list uses that mapping. The same order reads ‘Cancelled’ in the list but ‘void’ on its own detail page.
- Change: Wrap the detail-page status badge (and any other place that prints order.status raw) in the existing statusLabel() helper from nuxt/app/utils/statusColor.ts, so ‘void’ shows as ‘Cancelled’, ‘on_hold’ as ‘On Hold’, etc., matching the list. Keep the tenant’s custom hex colour as-is.
- Why it helps the owner: A shop owner who cancelled an order sees it labelled ‘Cancelled’ in the list but ‘void’ on the order’s own page today, which makes them wonder if it’s two different states; instead both screens say ‘Cancelled’, so the order’s status is unambiguous wherever they look.
- Surface unread store messages to customers with a badge instead of a collapsed section - 🟠 Med · effort M
- Today: On the storefront order detail (frontstore/app/pages/profile/orders/[id].vue) the ‘Updates & Messages’ section is collapsed by default with no count, so a customer who just got a message or proof from the shop has zero visual cue. They’d have to scroll down and expand it to discover the shop is waiting on them - so replies stall and the shop owner ends up chasing by phone.
- Change: Add an unread-message/new-update count badge on the ‘Updates & Messages’ header (driven by the existing History/Messages thread data), and auto-expand the section when there is an unread store message or a newly attached proof. Mark as read once viewed so the badge clears.
- Why it helps the owner: A shop owner who messages a customer asking ‘approve this proof?’ today often hears nothing because the customer never noticed the collapsed section; instead the customer sees a clear ‘New message’ badge and the panel opens itself, so proofs get approved faster and the owner stops playing phone tag.
Quotes
- Alert the owner the moment a storefront quote request arrives - 🔴 High · effort M
- Today: When a visitor submits the /request-quote form (RequestQuoteForm.vue -> StorefrontQuoteController@store), the new quote lands in the /quotes list with status ‘new’ and source ‘storefront’, but nothing tells the owner. There is no badge, no email, no Action Center entry, no sound. The ExpiringQuotesRule only fires when a quote is 7 days from expiry, never when one comes in. A non-technical owner who isn’t habitually refreshing the Quotes list will simply never see the request, and the customer who was promised a reply ‘within 24 hours’ gets silence.
- Change: Add a ‘New quote requests’ rule to the Action Center (mirror ExpiringQuotesRule.php as a NewStorefrontQuotesRule that selects quotes where source=‘storefront’ AND status=‘new’ AND not yet opened, severity ‘info’/‘warning’, link /quotes/{id}), AND send the owner a notification email/in-app notification on StorefrontQuoteController@store using the existing notification mechanism. The Quotes nav item should also show an unread-count badge for storefront requests not yet acted on.
- Why it helps the owner: A store owner trying to win a job today has to remember to open the Quotes list and eyeball it for new rows; if they forget for a day the lead goes cold. Instead the request pops into the same Action Center feed they already check each morning and pings their inbox, so they reply while the customer is still shopping.
- Fix the status colours so owners can scan which quotes need action - 🔴 High · effort S
- Today: In nuxt/app/components/quote/List.vue getStatusColor() (lines 334-349) only maps new/draft (blue), open (red), paid (green) and void (gray). The real lifecycle is new -> draft -> open -> accepted -> expired/canceled, so ‘accepted’, ‘expired’ and ‘canceled’ all fall through to the gray fallback and look identical. An owner scanning the list cannot tell at a glance which quotes were won, which lapsed, and which were killed - everything that matters most is the same grey. Worse, ‘open’ (a quote actively awaiting the customer) is shown in alarm-red as if it were a problem.
- Change: Rewrite getStatusColor to cover the actual enum: accepted = green (won), open = amber/yellow (waiting on customer), new/draft = blue (still being prepared), expired = orange (lapsed, may be revivable), canceled/void = gray. Pull these from the same statusLabel()/status colour source the rest of the app uses so they stay consistent, and use the project’s statusColor.ts dot convention rather than ad-hoc strings.
- Why it helps the owner: A store owner reviewing their pipeline today cannot tell a won quote from a dead one because both are grey, so they re-chase customers who already accepted or waste effort on lapsed quotes. With distinct colours they instantly see green = paid me, amber = chase the customer, orange = offer to renew.
- Give the storefront customer a reference number and tracking link after they request a quote - 🔴 High · effort M
- Today: RequestQuoteForm.vue submitQuote() shows only a toast (‘Quote request submitted successfully! We’ll get back to you within 24 hours.’) and then resetForm(). The customer gets no quote number, no confirmation email, and no way to look up the request. They cannot reference it when they call, and the store owner has no shared handle to discuss it either. Logged-in customers also can’t find it under /profile/quotes until the owner manually progresses it.
- Change: On successful submit, return the generated quote_number from StorefrontQuoteController@store and show a confirmation panel (not just a toast): ‘Thanks! Your request is Quote #00123. We’ll email you a price within 24 hours.’ plus a ‘View my requests’ link to /profile/quotes for logged-in customers. Send the customer an automatic acknowledgement email with the same reference. Replace the hardcoded ‘24 hours’ copy so it reflects the chosen delivery timeline.
- Why it helps the owner: A store owner whose customer phones to ask ‘did you get my request?’ currently has nothing to match them against and has to dig through the list by name. With a reference number on both sides, the customer says ‘Quote #123’ and the owner finds it instantly, and the auto-acknowledgement stops the owner from fielding ‘did it go through?’ calls at all.
- Rename and explain the ‘Client Page Link’ and clarify when to use it vs Email PDF - 🟠 Med · effort S
- Today: The quote detail page exposes a ‘Client Page Link’ (to /quotes/payment/{id}) and a separate ‘Email PDF’ button. ‘Client Page Link’ is a developer’s name; a non-technical owner has no idea this is the page where the customer accepts and pays, nor when to copy it versus when to hit Email PDF. The two actions look unrelated even though they’re two ways to do the same thing (get the customer to accept).
- Change: Relabel ‘Client Page Link’ to ‘Copy customer accept-and-pay link’ with a one-line helper: ‘Share this if you’d rather send the quote yourself (WhatsApp, SMS) instead of emailing it.’ Group it directly beside ‘Email PDF’ under a single ‘Send to customer’ heading, and on the Email PDF flow note ‘This email already includes the accept-and-pay link’ so the owner understands the two are equivalent and never sends both confusingly.
- Why it helps the owner: A store owner who wants to send a quote over WhatsApp today sees a cryptic ‘Client Page Link’ and doesn’t realise that’s the thing to paste; they fall back to email even when the customer prefers chat. With a plain label and grouping they confidently pick the channel their customer actually uses.
- Hide raw status mechanics from quote creation and the Accounting ‘Unposted’ label - 🟠 Med · effort M
- Today: The New Quote modal (Add.vue) forces the owner to pick from a generic ‘Status’ dropdown (quote_status: new/draft/open) with no guidance - a non-technical owner cannot know the difference and may set ‘open’ before the quote even has line items. Separately the detail page permanently shows ‘Unposted’ under Accounting with no explanation, leaving the owner wondering if they forgot a step. Both expose internal bookkeeping concepts the owner shouldn’t have to reason about.
- Change: Remove the Status dropdown from the default (collapsed) New Quote form and always create as ‘Draft’ - status should advance automatically (Draft until ‘Email PDF’/share, which already flips it to ‘open’ via sendToCustomer()). Leave manual status only in the advanced ‘Show All Fields’ section for power users, with plain descriptions (‘Draft - still preparing’, ‘Open - sent, waiting on customer’). Replace the bare ‘Unposted’ accounting label with a tooltip-explained plain phrase such as ‘Not yet in your books (becomes an invoice when the customer accepts)’.
- Why it helps the owner: A store owner creating their first quote today is asked to choose a status they don’t understand and stares at ‘Unposted’ wondering what they did wrong. Instead the quote just starts as a Draft and advances itself, so the owner only thinks about the customer and the price - never the plumbing.
- Remove or wire up the dead quote surfaces and the fake customer-facing data - 🟠 Med · effort L
- Today: Several quote surfaces are non-functional or show fake data that erodes trust. The /quote.vue cart-based builder’s submitQuote() only console.logs and never hits the API, so it silently does nothing. The frontstore /quote calculator shows hardcoded contact info (‘+1 (555) 123-4567’, ‘quotes@printflow360.com’, ‘Mon-Fri 9AM-6PM EST’) and a hardcoded ‘Your Recent Quotes’ list (Business Cards/Flyers/Brochures at fixed prices) that is not the customer’s real data. The /profile/quotes list also shows ‘Paid’ and ‘Void’ filter tabs for statuses the workflow never produces, so customers click them and see an unexplained blank.
- Change: Either gate these behind a feature flag until finished or fix them: make /quote.vue submit to the real quote endpoint (or remove the page); replace the hardcoded contact block with the tenant’s real store settings (phone/email/hours) and the dummy ‘Recent Quotes’ with the customer’s actual GET /frontstore/customer/quotes data (with proper empty state); and drop the ‘Paid’/‘Void’ tabs from /profile/quotes to match the real lifecycle (or relabel to states that exist, e.g. ‘Accepted’/‘Expired’).
- Why it helps the owner: A store owner expects every page their customer sees to be theirs; today a customer can land on a quote page showing a US phone number that isn’t the shop’s and prices that aren’t theirs, which makes the shop look broken or fraudulent. Removing the fake data and dead buttons means the owner never has to apologise for a page they didn’t know existed.
Invoices, Payments & Accounting
- Make “Mark as Paid” record the actual payment, not just flip a status - 🔴 High · effort M
- Today: The “Mark as Paid” action (POST /invoices/{id}/mark-as-paid, invoices/[id].vue line 269) only sets status=‘paid’. It shows no confirmation, never asks how much was paid or by what method, and creates no payment row. The owner who just received $500 cash clicks it, the invoice goes green, but the payment history stays empty and “Amount Paid” stays $0 - there is no record of when or how they were paid.
- Change: Replace the silent status flip with a small “Record Payment” dialog that pre-fills the full Balance Due as the amount, defaults the date to today, and requires a payment method (Cash / Cheque / Bank Transfer / Card). On confirm, create a real payment row (paymentable_type = Invoice) AND set the status to paid. Keep the amount editable so partial payments leave the invoice ‘open’ with a correct Balance Due. The confirm button reads “Record $X.XX Payment”.
- Why it helps the owner: A store owner who takes a cash payment at the counter today gets a green invoice but no proof of payment in the history. Instead, one click captures the amount, method, and date - so when the customer disputes it next month, the owner can show exactly what was paid and how.
- Fix or hide the broken Stripe invoice-payment path - 🔴 High · effort L
- Today: On the customer payment-select page (payment/select/[id].vue line 106), choosing Stripe routes to /invoices/payment/stripe/[id], which POSTs to /stripe/create-checkout-session/{id} - a route that exists in no route file. The customer gets a raw “Failed to initialize Stripe payment.” with no recovery. An owner with Stripe enabled believes invoice payment works; it silently dies the moment a real customer tries to pay.
- Change: Wire the Stripe invoice button into the working multi-gateway abstraction (CheckoutPaymentService / PaymentGatewayRegistry) that already drives storefront Stripe redirects, reusing the same session-creation flow that webhooks (UpdatePaymentAndOrder) already mark paid. If that cannot ship immediately, gate Stripe out of the invoice gateway list (filter it from activeGateways here) so it never appears as a payable option until it works - never show a method that errors on click.
- Why it helps the owner: A store owner who emails a customer a Stripe pay link today sends them into a dead error page and looks unprofessional. Instead the customer completes payment in Stripe’s checkout and the invoice auto-marks paid, exactly like storefront orders already do.
- Include admin-entered invoice payments in the Accounting totals and export - 🔴 High · effort M
- Today: AccountingController::tenantPaymentsQuery() joins exclusively through orders, so every payment with paymentable_type = Invoice (cash/cheque/bank transfer entered via the admin New Payment form) is excluded from the Accounting page KPIs, the This Month/This Year revenue, the payment-method breakdown, and the CSV export. The owner sees those payments inside each invoice but never in the summary.
- Change: Broaden the accounting payments query to union order-based payments with invoice-based payments (status=‘paid’), keyed by the same tenant scope, so all collected money appears in the KPI cards, method breakdown, transaction log, and CSV. Add the invoice number / customer as the source label on invoice rows so the log stays readable.
- Why it helps the owner: A store owner who exports the Accounting CSV to hand their accountant today gives them numbers that silently omit every cash and cheque payment - the totals are wrong. Instead the export reflects every dollar collected, so their books reconcile.
- Remove the hardcoded “Unposted” badge from the invoice detail - 🔴 High · effort S
- Today: invoices/[id].vue (lines 427 and 624) renders a permanent orange “Unposted” label with no backing database field and no way to ever change it. There is no ‘post’ action and no explanation. A non-technical owner reads a permanent orange warning on every invoice and assumes something is broken or unfinished.
- Change: Delete the “Unposted” label entirely (both occurrences). If a real accounting state is wanted later, replace it with a meaningful, backed status with a tooltip - but ship the removal now so no invoice carries a meaningless orange flag.
- Why it helps the owner: A store owner glancing at an invoice today sees an alarming orange ‘Unposted’ tag and either worries or wastes time hunting for how to ‘post’ it. Instead the invoice shows only states that mean something they can act on.
- Make the customer pay-link clearly shareable and not an admin URL - 🟠 Med · effort M
- Today: The “Open Payment Page” link points to the admin app URL /invoices/payment/select/{id}, which the owner is expected to copy and paste to the customer by hand. There is no “Copy link” affordance, no indication it is an admin-side URL, and while hasActiveGateway is still loading the link area is simply blank with no feedback.
- Change: Replace the bare link with a “Copy payment link” button that copies a public, tokenized payment URL (storefront/standalone host, not the admin app) and shows a “Link copied - ready to send to your customer” toast. Render a skeleton/disabled state with ‘Checking payment methods…’ while gateways load instead of a blank gap, and when no gateway is active show the existing amber ‘configure a gateway’ guidance inline.
- Why it helps the owner: A store owner who wants a customer to pay an invoice today must hand-copy an admin URL that may dump the customer into the staff panel. Instead they click Copy, paste it into an email or text, and the customer lands on a clean public payment page.
- Stop showing a card form for Cheque, and give the invoice status a sensible default - 🟠 Med · effort M
- Today: Two creation/flow defaults trip up first-timers: (1) selecting ‘Cheque’ on the payment-select page routes to the Authorize.Net card-entry form with ?method=cheque ignored (select/[id].vue line 109), so a cheque-paying customer is shown a card number/CVV form; (2) the New Invoice modal forces the owner to pick status new/draft/open with no default or tooltip.
- Change: For Cheque, route to a simple instructions screen (your cheque payee/mailing or bank-transfer details + ‘Mark this invoice as awaiting cheque’) instead of the card form. For new invoices, default the status to ‘Open’ (ready to send/collect) and add one-line plain-language hints under each option (‘Draft - not finished yet’, ‘Open - sent, awaiting payment’).
- Why it helps the owner: A first-time owner creating invoice number one today must guess between new/draft/open with no guidance, and a cheque-paying customer is shown a credit-card form. Instead the invoice defaults to the obvious ‘Open’, and choosing Cheque shows where to send the cheque - no wrong forms, no guessing.
- State the consequences in the refund confirmation - 🟠 Med · effort S
- Today: RefundConfirmationPopup.vue asks only “Are you sure you want to refund this payment?” with no amount, no statement of where the money goes (back to the card automatically vs. processed manually), and a generic confirm button - violating the destructive-action rule in CLAUDE.md section 0.
- Change: Show the refund amount, the payment method/last-4, and a plain-language consequence (‘This will refund $120.00 to the customer’s card via Authorize.Net. This cannot be undone.’ or, for manual methods, ‘This marks the payment refunded - you must return the money to the customer yourself.’). Label the button “Refund $120.00”.
- Why it helps the owner: A store owner refunding a payment today can’t tell from the dialog how much is leaving or whether the system actually moves the money. Instead they see the exact amount and whether it auto-refunds or needs a manual hand-back, so they don’t double-refund a customer.
Customers & Activity
- Show the engagement columns by default instead of hiding them behind a column-config icon - 🔴 High · effort S
- Today: The default view seeded for every tenant (TenantOnboardingService.php line 213) only includes 7 columns: company_name, account_number, tags, primary_contact_name, primary_contact_email, primary_contact_number, actions. Last Login, Last Order, Type, Total Spent, and Orders are all absent. A store owner opening the Customers page for the first time sees a plain address book and none of the engagement data the feature was built to provide. To see it they must find the unlabeled table-cells icon, guess what it does, and toggle each column on.
- Change: Add last_order_at, total_spent, orders_count, and last_login_at to the seeded default view column list in TenantOnboardingService.php, and add a one-time migration/back-fill that adds these columns to existing tenants’ default view rows that pre-date the activity feature. The list already renders all of these cells correctly (List.vue lines 208-227); only the seeded default omits them.
- Why it helps the owner: A store owner who opens Customers today to answer ‘who are my best repeat customers and who’s gone quiet?’ sees an address book with no spend, order, or login data and has to discover a cryptic icon to surface it; instead they would see total spend, order count, last order and last login the moment the page loads, with zero configuration.
- Fix the raw ‘Manual’ sub-label under each customer name to read ‘Added by store’ - 🔴 High · effort S
- Today: The always-visible sub-label inside the Company Name column (List.vue line 145) renders {{ row.customer_type || ‘guest’ }} with CSS capitalize, so a contact the owner added themselves shows as ‘Manual’. The codebase already has customerTypeBadge() in customerType.ts that maps the internal ‘manual’ value to the human label ‘Added by store’ (and ‘guest’→‘Guest’, etc.), but this sub-label bypasses it and prints the raw database string. The owner sees the word ‘Manual’ with no way to know what it means.
- Change: In List.vue line 145, replace {{ row.customer_type || ‘guest’ }} with customerTypeBadge(row.customer_type).label so the sub-label reuses the same plain-language mapping as the dedicated Type column. This also makes the inline label and the optional Type column show identical wording for the same customer.
- Why it helps the owner: A store owner scanning their list today sees a contact they personally entered labelled ‘Manual’ and cannot tell it apart from a developer term; instead they would see ‘Added by store’, immediately understanding this person can’t log in until invited and isn’t a real storefront sign-up.
- Replace the ‘(N/A)’ detail-page placeholders with click-to-fix empty states - 🔴 High · effort S
- Today: The customer detail page uses developer-style fallbacks: ‘Primary Name (N/A)’ (line 404), ‘Primary Email (N/A)’ (line 414), ‘Primary Phone (N/A)’ (line 428), and ‘Tags (N/A)’ (line 290). CLAUDE.md section 0 explicitly bans this pattern and calls for an actionable empty state. The fields are already inline-editable, but ‘(N/A)’ gives the owner no signal that the blank is fixable or how.
- Change: Swap each ’… (N/A)’ string for an actionable placeholder tied to the existing inline-edit affordance, e.g. ‘No name added - click to add’, ‘No email added - click to add’, ‘No phone added - click to add’, and for tags ‘No tags yet - click to add’. Render these in muted text so they read as a prompt, not data.
- Why it helps the owner: A store owner viewing a half-complete contact today sees ‘Primary Email (N/A)’ and assumes the system is broken or that nothing can be done; instead they would see ‘No email added - click to add’ and know exactly how to complete the record on the spot.
- Turn the four KPI cards into engagement-actionable counts - 🟠 Med · effort M
- Today: The four KPI cards are Total Customers, Active Customers, Archived Customers, and New This Month. Archived Customers is near-useless as a headline metric, and none of the cards reflect the engagement data the feature captures. An owner wanting to act on ‘who never logged in’ or ‘who hasn’t ordered’ has to manually build a filter rather than see the number surfaced and clickable.
- Change: Replace the ‘Archived Customers’ KPI with a ‘Never Logged In’ count (customers where last_login_at is null and source is not ‘admin’), and make each KPI card click through to the list pre-filtered to that segment (the list already supports Last Login date-range and status filtering via the funnel modal). Keep Total, New This Month, and Active; move Archived behind the existing Active/Archived toggle where it already lives.
- Why it helps the owner: A store owner who wants to re-engage quiet customers today must understand the filter modal and construct a last-login condition by hand; instead they would see ‘Never Logged In: 23’ on load, click it, and land on exactly that list ready to email or call.
- Explain what ‘Login as Customer’ does before and after it runs, and guide recovery when no store is configured - 🟠 Med · effort S
- Today: Clicking ‘Login as Customer’ (list row icon and detail-page button) opens the storefront in a new tab with a 30-minute HMAC token (CustomerController@loginAs line 735) but no on-screen explanation that the owner is now impersonating the customer or that the session expires. If the primary store’s frontstore URL can’t be resolved (no primary store configured), the owner gets a dead link with no guidance on what to fix.
- Change: On a successful loginAs, show a plain-language toast such as ‘Opening the storefront as this customer in a new tab. This temporary session expires in 30 minutes.’ When the store URL can’t be resolved, replace the failed link with an actionable error toast: ‘No storefront is set up for this customer’s store yet. Set one up in Settings › Stores to use Login as Customer.’ with a link to that path, matching the unconfigured-integration pattern in CLAUDE.md section 0.
- Why it helps the owner: A store owner clicking ‘Login as Customer’ to reproduce a checkout problem today either gets a silently-opened tab they don’t understand or a broken link with no explanation; instead they would get a clear confirmation of what’s happening and, on failure, a direct instruction on what to configure.
- Recover gracefully when a tenant has no seeded customer view instead of showing ‘Invalid view’ on an empty table - 🟠 Med · effort M
- Today: The list depends on a default view row seeded at onboarding. A store onboarded before the customer default view existed hits an ‘Invalid view’ error toast on load and an empty table, with no path to recover without backend intervention. For a non-technical owner this reads as the whole Customers page being broken.
- Change: When no valid view is returned, have the list fall back to a built-in default column set (the same 7-to-11 columns) and silently self-heal by creating the missing default view row for that tenant, rather than surfacing an ‘Invalid view’ toast and a blank table. Show the table normally; log the repair server-side.
- Why it helps the owner: A store owner on an older tenant who opens Customers today sees an error and an empty screen and assumes their customer data is gone; instead the page loads their customers normally and quietly fixes the missing-view configuration behind the scenes.
B2B / Business Accounts
- Show the negotiated price on product listing cards, not just inside the configurator - 🔴 High · effort M
- Today: StorefrontProductResource (app/Http/Resources/Api/Storefront/StorefrontProductResource.php:21) returns raw base_price with no per-company discount, so a logged-in B2B buyer browsing the catalog sees normal store prices on every product card. The discounted price they will actually pay only appears once they open the product configurator. The shop owner has promised a client ‘your prices are 15% lower’ and the catalog visibly contradicts that promise.
- Change: Thread the company_account_id context already used by ProductPricingCalculator into the listing path so StorefrontProductResource applies CompanyPricingResolver to base_price for logged-in B2B customers. Show the discounted price on the card with the original struck through and a small ‘Your business price’ badge. Keep guest/B2C cards unchanged. The cache key already includes :company:{id} (CacheApiResponse), so per-company prices won’t leak.
- Why it helps the owner: A store owner who just told their corporate client ‘you get 15% off everywhere’ can today be undermined the moment that client browses, because the catalog shows full price and generates ‘why am I being overcharged?’ support emails. Instead the client sees their agreed price from the first page they land on, and the owner never has to explain the discrepancy.
- Let the owner create a brand-new customer directly from the Contacts tab - 🔴 High · effort M
- Today: The Add Contact flow (nuxt/app/pages/business-accounts/[id].vue:482) only searches existing customers via api.searchCustomers and offers no way to create a person who has never shopped before. If a B2B buyer has never registered, the search returns nothing and the owner hits a silent dead end with no instruction on what to do next.
- Change: When the contact search returns no match, surface a ‘Add a new person to this company’ button in the empty state that opens a small inline form (name, email, role, department, allow-pay-on-account). On submit, create the Customer pre-linked to this company (account_type=b2b, company_account_id set) and, if the company has allowed_email_domains, validate the email against them with the existing plain-language message. Optionally send them a set-password invite.
- Why it helps the owner: A store owner onboarding ‘Globex Corp’ with five named buyers today must ask each buyer to go self-register on the storefront first, then come back and search for them one by one. Instead the owner types in all five people on the spot during the sales call and the account is ready to order immediately.
- Replace the signed manual-adjustment field with a Charge/Credit choice - 🔴 High · effort S
- Today: The Account & Credit tab’s manual adjustment uses a signed-number convention (negative = charge the company, positive = credit them) with only small helper text. A non-accountant store owner correcting a billing mistake will frequently enter the wrong sign, posting a backwards ledger entry that then needs a second adjustment to undo.
- Change: Replace the single signed input with two controls: a ‘Type’ toggle (‘Charge the company more’ / ‘Give the company credit’) and an always-positive amount field. Show a live plain-language preview: ‘This will increase Globex Corp’s balance owing by $50.’ before they confirm. Keep the note required. Convert to the signed ledger amount under the hood so the append-only ledger is unchanged.
- Why it helps the owner: A store owner fixing a $50 overcharge today must remember whether to type +50 or -50, and getting it wrong doubles the error and the cleanup. Instead they pick ‘Give the company credit’, type 50, read ‘reduces what they owe by $50’, and confirm with no accounting knowledge required.
- Surface a ‘No business stores yet - set one up’ path before the Add Account modal - 🔴 High · effort S
- Today: B2B activation depends on having a Business/Both store, but the only guidance is an amber warning buried inside the Add Business Account modal (Form.vue:15-19) that the owner sees only after clicking Add and finding the store dropdown empty with a disabled Create button. The connection ‘I must first set a store’s type to Business’ is never offered proactively from the Business Accounts section.
- Change: On the Business Accounts list page, when the tenant has zero business-facing stores, replace the empty state with a guided card: ‘To sell to companies, first set a store up for business. Choose which store sells to businesses → ’ linking directly (absolute path) to that store’s edit page with the ‘Who will buy from this store?’ field. Also show this as the modal’s body instead of a disabled form when no eligible store exists.
- Why it helps the owner: A store owner who wants to start selling to a company today clicks Add Business Account, finds an empty dropdown and a greyed-out button, and is left guessing what’s wrong. Instead they get a one-click path to the exact setting that unlocks the feature, and reach a working create form on the next screen.
- Tell the owner the role and department-budget fields are informational today - 🟠 Med · effort S
- Today: The Departments tab accepts a monthly budget that is captured but never enforced at checkout, and contact Roles (Buyer/Manager/Account Admin) are shown prominently but today only gate who can view the statement. A store owner who sets a $2,000 marketing budget expects orders to stop at that limit, and someone marked ‘Account Admin’ is expected to have admin powers - neither is true, with nothing in the UI saying so.
- Change: Add an inline helper under the budget field: ‘Recorded for your reference and reporting. Orders are not yet blocked when a budget is reached.’ Add a small tooltip/helper next to the Role selector: ‘Controls who can view the company statement on the storefront. Ordering and approvals are not yet limited by role.’ Plain text, no roadmap jargon.
- Why it helps the owner: A store owner relying on a department budget to cap a client’s spend today believes orders will be stopped at the limit and only finds out they weren’t after an overspend. Instead they know upfront the figure is for reporting, and set expectations with their client accordingly.
- Show a proper loading skeleton for the Account & Credit tab and flag the private-store default - 🟠 Med · effort S
- Today: The Account & Credit tab is lazy-loaded on first click but has no credit-specific skeleton, so the first click briefly shows a blank panel that reads as ‘no credit set up’ to a non-technical owner. Separately, Business stores default to login-only (require_login) but that default is set silently in Store.requiresStorefrontLogin() with nothing telling the owner their storefront is now private until a customer complains they cannot browse.
- Change: Add the standard skeleton/spinner to the Account & Credit tab while its data loads (matching the other four tabs). On the store edit page, when store type is set to Business, show an inline note next to the type field: ‘Business stores are private by default - only logged-in customers can browse. You can change this under Store Settings → Require login to access this store.’ with a link to that toggle.
- Why it helps the owner: A store owner opening a client’s credit tab today sees a blank panel and assumes credit isn’t configured, and separately discovers their public storefront went login-only only when a walk-in customer says they can’t see prices. Instead the credit numbers load behind a clear skeleton, and the private-store consequence is stated at the moment they choose ‘Business’.
Roles & Permissions (Team Access Control)
- Pre-fill each built-in role with the permissions its job actually needs - 🔴 High · effort M
- Today: All seven built-in roles (Designer, Sales Rep, Project Manager, etc.) are seeded with ZERO permissions. TenantOnboardingService::seedRolesAndPermissions() assigns nothing to non-admin roles, so the moment an owner adds a ‘Designer’ staff member, that person logs in and sees only the Dashboard - a blank nav that reads as a broken app, with no clue that the owner was supposed to configure the Designer role first.
- Change: Seed sensible default permissions per built-in role at onboarding (e.g. Designer = Editable on Print Jobs/Tasks + Visible on Quotes; Sales Rep = Editable on Quotes/Customers + Visible on Invoices; Project Manager = Visible across orders/jobs/customers). Keep them editable afterward. Add a small ‘Suggested for this role’ note in the edit screen so the owner sees these are starting defaults they can change. This is the §0 ‘sensible defaults so nobody has to configure’ rule applied directly.
- Why it helps the owner: A shop owner who hires a new designer today must know - with zero prompting - to first open Settings > Team > Roles > Designer and tick the right boxes, or the designer is locked out of everything; instead they could add the designer and have them immediately able to see jobs and tasks on first login, exactly what 90% of shops want.
- Tell the owner the new staff member gets an email with their password - 🔴 High · effort S
- Today: The Add User form (team/user/Add.vue) collects Name, Email, Phone, Role and a Save button - with no mention anywhere that the backend auto-generates a 15-char password and emails it via SendNewUserEmailJob. The owner has no idea the person will receive login credentials by email, so they either try to hand-set a password or tell the new hire a password that doesn’t exist.
- Change: Add a plain-language helper line under the Email field and an inline confirmation on save: ‘We’ll email [name] a link to set up their login. You don’t need to share a password.’ On success, replace the generic toast with ‘Invitation sent to jane@shop.com - they’ll receive login details by email.’
- Why it helps the owner: An owner adding their first sales rep today is left guessing how that person logs in and may phone them a made-up password; instead they could read one sentence on the form, hit Save, and confidently tell the hire ‘check your email’.
- Fix the edit-role page so it stops saying ‘Create New Role’ and lets the name be corrected - 🔴 High · effort S
- Today: In team/role/Upsert.vue the heading on line 196 is the hardcoded string ‘Create New Role’ regardless of mode, and the name input on line 200 is :disabled=“isEditMode”. So when an owner clicks the pencil to adjust the Sales Rep role, the page title says ‘Create New Role’ and the name field is greyed out - it looks like they’re about to make a confusing duplicate, and a typo’d role name can never be fixed.
- Change: Make the heading conditional: ‘Edit {{ title }} Role’ vs ‘Create New Role’. Remove the hard :disabled on the name input in edit mode (or allow rename with a confirm), so a mislabeled role like ‘Salse Rep’ can be corrected. Also delete the two console.log statements on lines 42 and 156 that dump permission internals into the browser console.
- Why it helps the owner: An owner editing the Sales Rep role today sees ‘Create New Role’ and reasonably backs out fearing they’ll create a second role, or is stuck forever with a typo in a role name; instead they could see ‘Edit Sales Rep Role’, trust they’re in the right place, and fix the spelling.
- Stop swallowing role save/load errors - show a plain message and a retry - 🔴 High · effort S
- Today: Every catch block in this feature only console.error()s: fetchRoleData (Upsert.vue line 87), savePermissions (line 178), fetchRolesData (List.vue line 32), and fetchRoles (Add.vue line 32). If the permissions list fails to load or a save fails, the owner sees absolutely nothing - no toast, no banner. They click Save again and again, or assume the app froze. This directly violates the §0 mandatory error-state rule.
- Change: On every catch, show a plain-language error toast with a recovery action: on save failure ‘We couldn’t save this role. Please check your connection and try again.’ (keep the form filled); on load failure render an inline ‘Couldn’t load permissions - Retry’ button instead of a dead screen. Never leave a catch as console-only.
- Why it helps the owner: An owner on a flaky shop wifi who hits Save and nothing happens today has no idea if the role saved or not and may corrupt their setup by re-clicking; instead they could see ‘couldn’t save, try again’, press retry, and know exactly where they stand.
- Add loading skeleton and a helpful empty state to the Roles list - 🟠 Med · effort S
- Today: In team/role/List.vue the loading ref only drives the ‘Load More’ button (line 104), so on first open the bordered list area is blank white until data arrives - looks broken on a slow connection. There is also no empty state: if nothing has loaded the user just sees an empty box with no message. Both violate the §0 mandatory loading + empty state rules.
- Change: Show 3-4 skeleton rows while the first fetch is pending, and when the list is genuinely empty show a helpful message with the next action: ‘No roles yet. Click “Add New Role” to create one, or use the built-in roles above.’ Reserve the same height so the layout doesn’t jump.
- Why it helps the owner: An owner opening Settings > Team on a slow connection today sees a blank white box and assumes team access is broken; instead they could see loading placeholders that resolve into their roles, or a clear prompt telling them what to do next.
- Warn how many staff are affected before deleting a role - 🟠 Med · effort M
- Today: Deleting a role (UtilsDeleteEntity in List.vue, label ‘role’) shows the generic ‘Are you sure you want to delete this role?’ - it never says how many team members are currently assigned (the list already loads role.users_count). An owner could wipe the access of several active staff with no warning, violating the §0 ‘destructive actions must state consequences’ rule.
- Change: Make the delete confirmation specific using the member count already available: ‘Deleting “Sales Rep” will remove access for the 3 team members currently using it. They won’t be able to log in until you assign them another role. This cannot be undone.’ Restate the action on the button (‘Delete Role’, not ‘Yes’). If users_count > 0, ideally require reassigning them first.
- Why it helps the owner: An owner cleaning up roles today can accidentally lock out three working sales reps mid-shift with a one-click ‘Yes’; instead they could see exactly who is affected and what breaks before confirming, and re-home those staff first.
- Stop showing access tiers the backend will silently ignore (Orders and other admin-only modules) - 🟠 Med · effort M
- Today: The role editor lets the owner grant Editable/Visible access to modules like Orders, but Orders is hardcoded to admin-only both in the backend route (middleware permission:is_admin) and the nav (Header.vue line 151 permissions:[‘is_admin’]). So an owner can carefully give a Project Manager order access, save it, and nothing happens - the module stays invisible with no explanation. Several other modules (CMS, Catalog, Marketing, Accounting) behave the same way.
- Change: In the role editor, mark modules that can’t currently be delegated as ‘Admin only’ (disabled tier with a tooltip: ‘Only Admins can access Orders right now’), instead of offering Editable/Visible toggles that the backend ignores. Better still, where the data model allows it, actually honor order.* permissions so the toggle works - but at minimum never present a control that silently does nothing (§0 ‘no half-built UI’).
- Why it helps the owner: An owner trying to let their project manager see incoming orders today spends time configuring permissions that have zero effect and then assumes the app is buggy; instead they could immediately see ‘Orders is Admin only’ and not waste effort, or actually grant the access and have it work.
Notifications & Automation
- Make the storefront customer notification toggles actually work (or remove them) - 🔴 High · effort M
- Today: In frontstore/app/pages/profile/settings.vue (lines 70-107) the customer Notifications tab shows three real-looking toggles - Order Updates, Promotions, Newsletter - but they are pure decorative HTML: no v-model, no @change, no API call. A customer who unchecks ‘Promotions’ or ‘Order Updates’ sees the switch move and assumes it saved, yet nothing is persisted and they keep getting every email. For a print-shop owner this becomes a support complaint (‘I turned off marketing emails and still get them’) with no setting they can point to.
- Change: Wire the three toggles to a real customer preference store. Bind each toggle to a v-model, call a PATCH endpoint (e.g. /storefront/profile/notification-preferences) on change, persist the choices on the customer record, and have the outbound email path (ActionExecutor + SendAppNotification for customer-facing mail) honour the ‘Promotions’/‘Newsletter’ flags before sending marketing-type emails (order-status emails should stay mandatory and the toggle for those should be removed or labelled ‘always on’). Add a ‘Saved’ toast on change and a loading/disabled state while saving. If wiring the backend is out of scope short-term, the toggles must be hidden, not shown as functional.
- Why it helps the owner: A shop owner whose customer asks ‘stop sending me promotions’ can today only apologise - the preference is silently discarded. Instead the customer flips one switch, it sticks, and the owner stops fielding ‘why am I still emailed’ complaints and stays compliant with unsubscribe expectations.
- Stop the email-channel notification bug that drops subscribed recipients - 🔴 High · effort S
- Today: In app/Jobs/Notification/SendAppNotification.php the email recipient resolution (line 96) calls getNotificationUsers(…, ‘push’) instead of ‘email’. This means staff who are correctly subscribed to an event by role but have their personal push toggle off (notifications_enabled = false) never receive the EMAIL either - email is silently gated by a push-only flag. A store owner who set up ‘email the Sales Rep when an invoice is paid’ gets no error and no email; they just quietly miss payments.
- Change: Fix line 96 to resolve recipients with channel ‘email’ and ensure the email path is gated by an email-specific preference (or no per-user gate), not by the push notifications_enabled flag. Add a Feature test asserting that a role-subscribed user with push disabled still receives the email notification for that event.
- Why it helps the owner: An owner relying on ‘email me when a proof is approved so I can start production’ is today missing some of those emails with zero visibility into why. After the fix, every recipient they configured in Settings > Notifications actually gets the email, so production doesn’t stall on a notification that never arrived.
- Replace the permanently greyed-out SMS toggle with an honest, explained state - 🔴 High · effort S
- Today: In nuxt/app/pages/setting/notifications.vue (lines 50-62) every one of the 37 event rows shows an SMS toggle hard-disabled with opacity-40 and zero explanation. A non-technical owner clicks it, nothing happens, and they conclude either the app is broken or that they’re missing something they paid for. It’s a confusing dead control repeated 37 times.
- Change: Either (a) hide the SMS column entirely until SMS sending is wired, or (b) keep it visible but add a tooltip / inline helper on hover and tap (‘Text message alerts are coming soon’ or ‘SMS requires a connected text provider - Set up in Settings > Integrations’) so the disabled state is self-explanatory. Per the project’s unconfigured-integration pattern, point to where to enable it rather than leaving a silent grey switch.
- Why it helps the owner: An owner scanning notification settings today wastes time poking a dead SMS switch and may open a support ticket. Instead they instantly understand SMS isn’t on yet and where it’ll live, and move on to configuring the channels that do work.
- Group the 37 flat event rows by category and hide rare edge-case events - 🟠 Med · effort M
- Today: Settings > Notifications renders all 37 event types as one undifferentiated flat list, labelled by a dot-to-space transform (formatEventLabel, line 140-141). A store owner sees ‘Invoice Created’, ‘Invoice Restored’, ‘Quote Restored’, ‘Inquiry …’ all jumbled together with no sense of which matter day-to-day versus edge cases like ‘Invoice Restored’. They can’t find the three events they actually care about.
- Change: Group rows under collapsible plain-language section headers by module (Invoices, Quotes, Orders, Production Jobs, Proofs, Customers, Inquiries) using the already-computed eventModules map. Within each group, surface the common operational events first and collapse rare/admin events (Restored, etc.) under a ‘More events’ expander. Improve the label map so technical-sounding events read in shop language (e.g. ‘invoice.paid_in_full’ → ‘Invoice Fully Paid’).
- Why it helps the owner: An owner who just wants ‘tell my production manager when a proof is approved’ today has to scan 37 rows to find it. Grouped and shop-worded, they jump straight to the Proofs section, flip one row, and trust they’ve covered the case that matters - instead of giving up unsure they got it right.
- Add a confirmation dialog with consequences before deleting an automation - 🟠 Med · effort S
- Today: In nuxt/app/components/automation/List.vue, deleteAutomation (line 228) fires the DELETE request immediately on clicking the red ‘Delete’ link - no confirmation, no warning. An automation may have run hundreds of times (‘ran N times’ counter) and be the thing emailing every customer a payment thank-you. A misclick silently destroys it and its run history, and the owner may not notice until customers stop getting emails.
- Change: Gate deleteAutomation behind a confirmation modal that states the consequence in plain language and restates the action on the button: e.g. ‘Delete “Thank-you on payment”? This automation has run 142 times. Customers will no longer be emailed automatically when they pay. This cannot be undone.’ Confirm button reads ‘Delete Automation’, not ‘Yes’. Match the destructive-confirm pattern used elsewhere in the admin.
- Why it helps the owner: A shop owner tidying up their automations today can wipe out a live customer-facing rule with one accidental click and no safety net. With a clear consequence-stating confirm, they see exactly what stops happening before they commit, so a tidy-up never silently breaks their customer emails.
- Surface silently-failing automations and triggers instead of run_count = 0 - 🟠 Med · effort L
- Today: Several automations can be enabled yet silently never do anything: the default ‘In Production’ / ‘Ready for Pickup’ rules reference hardcoded global job-status IDs (job-27, job-64) that may not match a tenant’s statuses, so ConditionValidator returns false and nothing fires; the task_completed trigger has no ConditionValidator branch (fires unconditionally); and assign_task / request_payment actions are null-matched in ActionExecutor and do nothing. The owner just sees an enabled automation with ‘ran 0 times’ and no clue why, or worse, emails firing for the wrong reason.
- Change: Three concrete fixes: (1) Make the seeded automations resolve job statuses by tenant-specific name/slug at onboarding rather than hardcoded IDs, and if a referenced status is missing, mark the automation as ‘Needs setup’ in the list with a plain note (‘This rule points to a status that doesn’t exist in your store - pick one to activate it’). (2) Remove task_completed, assign_task, and request_payment from the trigger/action pickers until they’re implemented (gate behind the feature flag pattern) so owners can’t build a rule that silently does nothing or fires unconditionally. (3) In the automation list, when an enabled rule has run_count = 0 after being created N days ago, show a subtle ‘Hasn’t run yet - check the condition’ hint.
- Why it helps the owner: An owner who set up ‘email the customer when their order is ready for pickup’ today sees it enabled, trusts it, and never learns it’s firing into a void because of a mismatched status ID - customers never get told their job is ready. With status-by-name resolution and a ‘Needs setup’ flag, the rule either works or visibly tells them what to fix, so the pickup email they rely on actually goes out.
Action Center
- Turn the email summary ON by default and surface it in plain sight - 🔴 High · effort S
- Today: The email digest is OFF by default and buried two clicks deep: the owner must navigate to /action-center, then open the ‘Manage alerts’ slideover, then find the ‘Email summary’ section. A non-technical owner who never discovers it will simply never receive overdue-invoice or late-job warnings on the days they don’t log in - which is exactly when they most need them.
- Change: Default the store-wide digest to ON (daily at 07:30) for new stores, and on first load of /action-center show a one-line confirmation banner at the top (‘We email you a daily summary of what needs attention. Change this in Manage alerts.’) with an inline ‘Turn off’ link. Keep the slideover for fine-tuning, but the default and the banner mean discovery is no longer required.
- Why it helps the owner: A shop owner who only logs in twice a week and is trying to avoid missing an overdue invoice must today stumble onto a hidden slideover and turn on emails they don’t know exist; instead the summary already lands in their inbox every morning, so a late job or unpaid invoice reaches them whether or not they log in.
- Replace developer severity labels with shopkeeper language everywhere - 🔴 High · effort S
- Today: The full page shows tab labels and per-row badges reading ‘Critical’, ‘Warning’, and ‘Info’ (action-center/index.vue lines 158-163 and 223-225). ‘Critical’ and ‘Info’ are developer terms, and they are inconsistent with the email digest template, which already uses the friendlier ‘Needs attention’ / ‘Good to know’. A non-technical owner reading ‘Info’ next to a dormant-customer alert has no idea whether it matters.
- Change: Map severities to plain language in the UI: critical -> ‘Urgent’, warning -> ‘Needs attention’, info -> ‘Good to know’. Apply the same map to the tab labels, the row badges, and the dashboard widget badge so the in-app wording matches the email exactly. Keep the colour dots (red/amber/blue) as the at-a-glance signal.
- Why it helps the owner: An owner scanning the feed to decide what to do first must today translate ‘Critical’ vs ‘Info’ in their head and then sees different words again in the email; instead ‘Urgent’ / ‘Needs attention’ / ‘Good to know’ read the same on screen and in the inbox, so they instantly know an overdue invoice (‘Urgent’) outranks a quiet customer (‘Good to know’).
- Give the dashboard widget a real error state instead of silently showing zero - 🔴 High · effort S
- Today: DashboardActionCenterWidget.vue has no error handling: fetchData() wraps the call in try/finally with no error ref, so if the API call fails the widget falls through to the ‘You’re all caught up’ empty state (items.length === 0). The owner sees a reassuring green check and ‘Nothing needs your attention’ when in reality the data never loaded - they could have three overdue invoices and believe everything is fine.
- Change: Add an
errorref set in a catch block (and on!res?.ok), and render a third state in the widget: an amber line ‘Couldn’t check what needs attention’ with a ‘Try again’ button calling fetchData() - mirroring the pattern already implemented on the full page (index.vue lines 201-205). Never let a failed load render as the all-caught-up state. - Why it helps the owner: An owner glancing at their dashboard on login to confirm nothing is on fire must today trust a green checkmark that can be a lie when the request fails; instead a visible ‘Couldn’t check - Try again’ tells them the list is unverified, so they don’t walk away from a silent overdue invoice believing they’re caught up.
- Add a labelled Action Center entry to the sidebar with a live count - 🔴 High · effort S
- Today: There is no sidebar link to /action-center (confirmed: only Header.vue and the dashboard widget reference it). The only ways back are the unlabelled bolt badge in the header - which has no visible tooltip on touch devices - and the dashboard widget title. An owner who dismisses the widget or doesn’t recognise the bolt icon has no obvious path to the feature.
- Change: Add a named sidebar item ‘Action Center’ (bolt icon) that links to /action-center, with the same live count badge the header already polls from /api/v1/action-center/count (red for critical, amber otherwise). Place it near the top, by Dashboard, since it’s a daily-attention surface.
- Why it helps the owner: An owner who wants to re-check their to-do list later in the day must today remember that a small unlabelled lightning icon in the header is the way in; instead they see a clearly labelled ‘Action Center’ in the main menu where they already look for everything else, with a number showing how many items are waiting.
- Make low-stock and quiet-customer alerts say what to do, not just state a number - 🟠 Med · effort M
- Today: The low-stock subtitle reads ‘Reorder threshold: 10’ - ‘threshold’ and a bare number are technical and give no instruction. Separately, the at-risk-customer rule fires for every customer with no order in 90+ days, which floods the feed with ‘Good to know’ items that bury urgent ones, and its View link just opens the customer record with no suggested next step.
- Change: Rewrite the low-stock subtitle to an instruction: ‘Only 3 left - you reorder when it drops below 10. Update this in the product’s stock settings.’ For quiet customers, cap the rule output low (e.g. top 5 highest-value lapsed customers rather than 50) so they never crowd out urgent items, and change the row action label from a generic ‘View’ to ‘Send a re-order reminder’ / ‘View customer’ pointing at the customer’s contact/re-engage action.
- Why it helps the owner: An owner who sees a low-stock alert must today decode ‘threshold: 10’ and guess whether to act; instead they read ‘Only 3 left - reorder below 10’ and know immediately. And instead of scrolling past dozens of dormant-customer notices to find the one overdue invoice, they see only the few lapsed customers worth chasing, each with a clear ‘send a reminder’ action.
- Offer flexible snooze and a way to see what’s been snoozed - 🟠 Med · effort M
- Today: The three-dot menu offers only a single fixed ‘Snooze for 7 days’ option (index.vue line 146), and once snoozed an item vanishes with no list of what was snoozed or when it returns. An owner who wants to defer a job alert until its actual due date, or who forgot what they snoozed, has no recourse - the alert silently reappears in a week regardless of relevance.
- Change: Expand the snooze menu to ‘Until tomorrow’, ‘For 3 days’, ‘For a week’, and ‘Until a date…’ (date picker), and add a ‘Snoozed’ filter tab (alongside All/Urgent/etc.) listing snoozed items with their return date and an ‘Un-snooze’ action. The existing alert_dismissals snooze storage already records the snooze expiry, so the data is there to display.
- Why it helps the owner: An owner who snoozes a ‘proof awaiting sign-off’ alert because they just chased the client must today accept a rigid 7-day reappearance and can never review or undo it; instead they snooze it to the exact follow-up date and can open a ‘Snoozed’ tab to see everything they’ve parked and when it’ll come back.
Tenant Onboarding & Setup
- Auto-create the first store at registration so the owner never lands on a dead-end dashboard - 🔴 High · effort M
- Today: No store is created during sign-up. AuthController::register() creates only the tenant and seeds back-office defaults; StoreOnboardingService (which seeds the homepage, pages, sample products, shipping methods, and offline payment) only runs when the owner manually discovers Store Management > Add Store and submits the create form. Until they do that, the dashboard ‘Get your store ready’ checklist is broken: resolveStore() returns null, so checkLogoStep/checkShippingStep/checkPaymentGatewayStep all silently report ‘incomplete’, the ‘Configure Shipping’ link has no store ID to build a URL from, and there is no ‘Create your first store’ prompt anywhere on the dashboard. A non-technical owner is told to configure shipping but the link goes nowhere.
- Change: After the tenant is created and approved (auto-approve path immediately, manual-approve path inside AccountStatusService::approveTrial), automatically create one primary store named after the business and run app:new-store-created so StoreOnboardingService::seedAll() seeds the full storefront. Remove the requirement that the owner manually create their first store; keep ‘Add Store’ available only for adding additional stores. The checklist then resolves a real store on first login and every step link works.
- Why it helps the owner: A shop owner who just signed up today logs in to a dashboard whose checklist can never be completed because half the steps point at a store that does not exist yet, and nothing tells them to create one. Instead, they would log in to a working store with a real homepage and a checklist whose every step opens the correct page, so their first session is ‘add my logo and a product’, not ‘figure out why the buttons are broken’.
- Stop counting the seeded demo products as the owner’s ‘Add your first product’ step - 🔴 High · effort S
- Today: checkProductStep() in useSetupChecklist.ts marks ‘Add your first product’ complete as soon as /products total > 0, but TenantOnboardingService seeds 4 demo (is_demo) products at registration. So a brand-new owner who has added nothing sees that step pre-ticked and a false ‘1 of 5 complete’, while their real catalogue is empty. The products page separately shows an amber ‘sample products’ banner urging them to remove those very products - the two surfaces contradict each other.
- Change: Change the product step to count only non-demo products: pass a filter (e.g. ?exclude_demo=1 or is_demo=0) to the /products check so the step stays incomplete until the owner adds or publishes a real product, or edits a sample into a real one. Keep the demo products for storefront preview, but do not let them satisfy the setup milestone.
- Why it helps the owner: An owner trying to understand how much setup is left sees the checklist claim a product is already added when they have not touched the catalogue, so they may skip the most important step and go live with only sample products. Instead the step stays open until they have a genuine product, matching the ‘remove samples’ banner and guiding them to the one task that actually makes their store sellable.
- Replace the seeded placeholder storefront content that would otherwise go live verbatim - 🔴 High · effort M
- Today: StoreOnboardingService seeds customer-facing content that is wrong-by-default and easy to miss: the homepage Stats block shows hardcoded ‘10,000+ Orders Delivered’, ‘2,847 Orders This Week’, ‘4.9 Average Rating’; the footer social links point to generic platform homepages (facebook.com, instagram.com); and the hero banner has no image, falling back to an empty placeholder background. A non-technical owner who does not edit these will publish fake trust numbers and dead social links to real customers.
- Change: Seed honest neutral defaults instead of fake specifics: omit or hide the Stats block until the owner enters real numbers (or seed it empty with editable zero/’-’ values and a clear ‘Add your stats’ prompt in admin), set footer social fields to empty with placeholder helper text ‘Add your Facebook link’ rather than a live generic URL so empty ones simply do not render on the storefront, and either seed a tasteful default hero image or surface an inline admin prompt on the hero block (‘Add a hero image - recommended 1600x600’) so the empty hero is obviously a to-do, not a finished section.
- Why it helps the owner: An owner who previews their store and thinks it looks finished will unknowingly publish ‘4.9 Average Rating’ they never earned and social icons that send customers to facebook.com’s homepage. Instead the starter store contains nothing false; anything still blank visibly reads as ‘fill this in’, so they cannot accidentally go live with fake stats or dead links.
- Make the pending-approval wait self-serve instead of an email-only black box - 🟠 Med · effort M
- Today: With manual approval (auto_approve_signups=false) the owner is redirected to /auth/pending-approval, a holding page with four ‘while you wait’ tips, no way to check status, and no link into the dashboard even for read-only browsing - they can only wait for an email. Worse, subscription creation is commented out in register() and only happens at approval time, so a manually-approved tenant can reach the dashboard with no active subscription and hit opaque 402 responses from EnsureTenantHasActiveSubscription instead of a clear message.
- Change: On the pending-approval page add a ‘Check approval status’ button that re-fetches the account status and, once approved, routes straight into the dashboard, plus a ‘Take a look around’ read-only link so they can explore while waiting. Ensure approval always creates the trial subscription before the owner can act (it does via approveTrial, so the gap is the manual path reaching the dashboard pre-subscription); have EnsureTenantHasActiveSubscription return a plain-language ‘Your free trial starts as soon as we approve your account - we will email you’ message keyed on TRIAL_REQUESTED instead of a bare 402.
- Why it helps the owner: An owner waiting for approval today has no idea whether minutes or days remain and cannot do anything but refresh their inbox; if they slip through early they hit cryptic 402 errors. Instead they can press ‘Check status’ to get in the moment they are approved, browse their seeded store while waiting, and never see a raw payment error during the trial.
- Fix the ‘Onboard’ redirect to explain in plain language what business detail is missing - 🟠 Med · effort S
- Today: auth.ts redirects any user with status ‘Onboard’ to /setting/account with the toast ‘Please update business details to use system.’ - developer phrasing, and the destination is the full account settings form with no indication of which fields are required or why they were sent there. A non-technical owner sees a generic settings page and a vague instruction with no clear finish line.
- Change: Change the toast to plain language (‘Finish setting up your business details to unlock your dashboard’) and, on /setting/account when arriving in the Onboard state, show a short banner at the top naming exactly what is still needed and a single ‘Save and continue’ button that flips the user out of Onboard status and returns them to the dashboard, rather than leaving them in an open-ended settings form.
- Why it helps the owner: An owner sent to the settings page today reads broken-English developer instructions, does not know which of the many fields matter, and has no obvious way to get back to the dashboard. Instead they see exactly the two or three fields to fill, save once, and are taken straight to their store - a finishable task instead of a confusing detour.
- Show the full plan feature list and a clear empty/error state in the registration plan picker - 🔵 Low · effort S
- Today: Step 1 of /register renders each plan from /public/plans but the feature list shows only plan.features.description[0] (the first element), so the owner sees a single line and cannot tell what each tier actually includes. If /public/plans is empty the form skips silently to step 2 with no explanation, and there is no visible error/empty state for the plan fetch.
- Change: Render the full plan.features list (or the documented set of included features) per plan card with checkmarks instead of just the first line, and when no plans are returned show a brief ‘You’ll start on our standard free trial’ note rather than silently skipping the step, plus a plain-language retry state if the fetch fails.
- Why it helps the owner: An owner choosing a plan today sees one cryptic line per tier and cannot judge what they are paying for, or the plan step vanishes with no word; instead they see a clear feature comparison and always understand which plan (or default trial) they are starting on before they commit.
Integrations & Payment Gateways
- Show the webhook URL with a one-click copy and “why this matters” note on every payment gateway - 🔴 High · effort M
- Today: The webhook URL (https://
/api/v1/webhooks/payments/{gateway_key}) is never shown anywhere in the admin UI. To make Stripe or Razorpay actually confirm payments, the owner must already know to copy this URL from documentation and paste it into the gateway’s own dashboard. If they miss it or paste the wrong one, orders silently sit on ‘payment pending’ forever with no explanation, and the owner has no way to discover what went wrong. - Change: In the payment gateway configure form (PaymentGatewayCard.vue and the integrations form for payment-category services), add a clearly labelled, read-only ‘Payment confirmation URL’ field that renders the live per-store webhook URL with a copy button and a plain-language helper: ‘Paste this into your Stripe dashboard under Developers › Webhooks so we know when a customer has paid. Without it, paid orders may stay marked as Pending.’ Only render it for gateways that use webhooks (redirect drivers), not for offline/Cheque.
- Why it helps the owner: A store owner setting up Stripe today must already know a webhook exists, find the URL in docs they won’t read, and reconstruct their own store host by hand; instead they copy one labelled URL straight from the gateway card and paste it where the helper tells them to, so paid orders actually flip to Paid instead of getting stuck on Pending.
- Today: The webhook URL (https://
- Add a “Test connection” / “Send test” button after saving credentials - 🔴 High · effort L
- Today: There is no way to confirm credentials work after saving. After entering Stripe keys (or SMTP, or a shipping carrier) and clicking Save, the only feedback is ‘settings saved successfully’ - which only means the form stored the text, not that the keys are valid. The owner learns the keys are wrong only when a real customer’s checkout fails or a real order email never arrives.
- Change: On the configure form footer for each integration, add a ‘Test connection’ button next to Save (for email: ‘Send test email to me’; for payment: a sandbox auth/ping via the existing driver registry; for shipping: a sample rate quote). Show a plain result inline: green ‘Connection works - these credentials are valid’ or red ‘These credentials were rejected by Stripe. Double-check your Secret key and Mode (live vs test).’ Disable the activate toggle’s ‘success’ state messaging until a test has passed, or at least nudge them to test.
- Why it helps the owner: A shop owner who fat-fingers a Stripe secret key today only finds out when their first real customer can’t pay; instead they click ‘Test connection’, see it fail immediately with a hint about which field is wrong, and fix it before a single order is lost.
- Block activation of a half-configured gateway and warn on sandbox-in-production - 🔴 High · effort M
- Today: The activate toggle on both the Integrations index (toggle(item)) and the gateway card turns a service ‘on’ regardless of whether required fields are filled, and Mode (sandbox/live) is just a bare select with no warning. An owner can flip Stripe to Active with missing keys, or leave it in ‘sandbox’ mode with live keys - and the gateway then appears as a real payment option at checkout where every transaction silently fails.
- Change: Prevent toggling an integration to Active while its configuration_status is ‘incomplete’ - instead of activating, show a toast/inline message ‘Stripe still needs your Secret key before it can go live’ and open the configure form. When a payment gateway is set to Active in ‘sandbox’/‘test’ Mode, show a persistent amber banner on the card and at checkout-config level: ‘This gateway is in Test mode - real customers will not be charged. Switch to Live when you’re ready to take real payments.’
- Why it helps the owner: An owner who toggles Stripe on before finishing setup, or forgets it’s in test mode, today exposes a broken or fake payment option to real customers with no warning; instead they’re stopped at the toggle with a plain reason, and a standing ‘Test mode’ banner makes the live/sandbox mistake impossible to miss.
- Merge the duplicate Payment Gateways page into the Integrations hub (or make it a filtered view) and fix its deep links - 🔴 High · effort M
- Today: Payment gateways can be configured from two unrelated places: Settings › Integrations (the canonical hub) and Settings › Payment Gateways (/setting/payment-gateway), which is not in the side-nav, has no breadcrumb back, and is only reachable via deep links from Invoices/Quotes. An owner who sets up Stripe in one place may never realise the other exists, and the two can drift out of sync in their mind.
- Change: Make /setting/payment-gateway a thin redirect (or alias) to the Integrations hub pre-filtered to the Payment category (activeCategory=‘payment’), and repoint the ‘Set up a payment gateway →’ deep links on Invoice and Quote pages to that same filtered hub URL. Keep one canonical place to manage payment gateways. If the simpler card layout is preferred for payments, render it inside the hub under the Payment section rather than on a separate orphan page.
- Why it helps the owner: A shop owner who clicks ‘Set up a payment gateway’ from an unpaid invoice today lands on an orphan page with no way back and no idea it duplicates the Integrations hub; instead every path leads to the one Payments area, so they never wonder whether they configured Stripe ‘in the right place’.
- Replace the raw service_key subtitle and unhide/repair the Authorize.Net dead-end - 🟠 Med · effort S
- Today: PaymentGatewayCard.vue line 17 prints the internal service_key (‘stripe’, ‘razorpay’, ‘authorizenet’) as the card subtitle - a developer identifier a shop owner reads as a bug. Separately, Authorize.Net is offered as a checkout option but its embedded driver only shows ‘inline checkout is not yet available - please choose another payment method’, so an owner who activates it as their only gateway sends every customer into a dead-end.
- Change: In PaymentGatewayCard.vue, replace the service_key subtitle with a human descriptor (the category label ‘Payment provider’, or the template’s short description), never the raw key. For Authorize.Net: since the embedded flow is unfinished, either hide it from the catalog/checkout entirely behind a feature flag, or mark its card ‘Coming soon - not yet available for live checkout’ and prevent activating it, so it can never silently become a customer’s only option.
- Why it helps the owner: A shop owner reading ‘authorizenet’ under a gateway name today assumes something is broken; and one who picks Authorize.Net as their gateway dead-ends every customer. Instead the card reads in plain language, and the unfinished gateway can’t trap them or their buyers.
- Make the configuration status bar tell owners exactly which field is missing, with a direct fix link - 🟠 Med · effort M
- Today: The Configuration bar shows colour-coded badges (Configured / Partial / Incomplete) and a percentage-style width, but never names the specific missing required field. An owner who sees ‘Incomplete’ or ‘Partial’ has to click Configure and visually hunt the form for the blank required input, with no idea how many fields remain.
- Change: Have IntegrationResource.php return the list of missing required field labels (it already counts them server-side), and on the card render them in plain language under the status bar: ‘Still needed: Secret key, Webhook signing secret’ with a ‘Finish setup →’ button that deep-links into the configure form. Update the ‘X need setup’ header badge tooltip to say what ‘setup’ means (‘credentials still missing - customers can’t use these yet’).
- Why it helps the owner: An owner staring at a ‘Partial’ Stripe card today must open the form and scan every field to find the one blank box; instead the card tells them ‘Still needed: Webhook signing secret’ and one click takes them straight to finishing it.
Storefront Themes & CMS
- Make Typography, Layout, and Buttons reachable from the section tabs - 🔴 High · effort S
- Today: In theme.vue the sticky section nav (
sectionsarray, lines 699-709) lists Preset, Footer, Products, Banners, Homepage, Category, Cart, Social, and Custom Code - but NOT Typography, Layout, or Buttons. Those three sections render in the page (they exist inopenSectionsand in the HTML) and hold core look-and-feel controls (fonts, heading scale, border radius, container width, button style, shadow), yet there is no tab that jumps to them. An owner can only reach them by scrolling past the Preset picker and stumbling onto them. - Change: Add three entries to the
sectionsarray immediately after Preset:{ id: 'typography', label: 'Fonts & Text' },{ id: 'layout', label: 'Layout' },{ id: 'buttons', label: 'Buttons' }, each with the matching icon and keywords, so they appear as clickable tabs and the existing scroll-to/active-highlight behaviour works for them like every other section. - Why it helps the owner: A store owner who wants to change their storefront’s heading font or make corners less rounded today has to scroll blindly past the theme picker and hope they find the right controls; instead they could click a clearly labelled ‘Fonts & Text’ or ‘Layout’ tab and land directly on those settings, the same way they reach Banners or Cart.
- Today: In theme.vue the sticky section nav (
- Replace music-theory ‘Heading Scale’ labels with plain visual descriptions - 🔴 High · effort S
- Today: The Heading Scale dropdown (theme.vue lines 211-212) offers
['minor-second','major-second','minor-third','major-third','perfect-fourth']as both the values AND the labels. These are typographic ratio names that mean nothing to a print-shop owner - there is no hint that they control how much bigger headings are than body text. - Change: Keep the same stored values but render human labels in the USelectMenu: ‘Subtle (headings slightly larger)’, ‘Gentle’, ‘Balanced - recommended’, ‘Bold (clear size jumps)’, ‘Dramatic (very large headings)’. Map label→value so saved data is unchanged, and mark the current default (
major-third) as ‘Balanced - recommended’. - Why it helps the owner: An owner who wants their product page headings to stand out more today sees five cryptic terms like ‘perfect-fourth’ and has to guess; instead they could pick ‘Bold (clear size jumps)’ and understand exactly what they’ll get without trial-and-error refreshing of the storefront.
- Today: The Heading Scale dropdown (theme.vue lines 211-212) offers
- Let owners set banner autoplay in seconds, not milliseconds - 🔴 High · effort S
- Today: The banner autoplay field (theme.vue line 399-400) is labelled ‘Autoplay Interval (ms)’ and is a raw number input with
step=500, defaulting to 6000. A non-technical owner does not know that 6 seconds means typing 6000, and may enter ‘6’ expecting six seconds - producing a banner that flips every 6 milliseconds. - Change: Change the label to ‘Seconds between slides’, display the value divided by 1000 (so 6000 shows as 6), accept whole/half seconds, and convert back to milliseconds on save. Add helper text ‘How long each banner stays on screen before sliding to the next.’
- Why it helps the owner: An owner setting up a rotating homepage banner today must know the hidden rule that the unit is milliseconds and multiply by 1000; instead they could type ‘6’ for a six-second slide and trust it does what it says.
- Today: The banner autoplay field (theme.vue line 399-400) is labelled ‘Autoplay Interval (ms)’ and is a raw number input with
- Cross-link Banner content and Banner behaviour so owners find both halves - 🟠 Med · effort S
- Today: Banner images and text (the content) live in Banner Management, while banner autoplay/height/animation live in Theme Settings › Banners - two completely separate screens with no mention of each other. An owner who changes a banner image and wants it to rotate faster has no way of knowing the speed control is on a different page entirely.
- Change: Add a short banner at the top of Theme Settings › Banners: ‘These settings control how banners behave. To change banner images and text, go to Banner Management →’ (linking to the store’s Banners page). Add the reverse note on the Banner Management list: ‘To change autoplay speed, height, and animation, go to Theme Settings › Banners →’. Both links absolute and store-scoped.
- Why it helps the owner: An owner who just uploaded a new hero image and wants it to auto-rotate must currently discover, by luck, that the speed setting lives on an unrelated screen; instead each screen points them straight to its other half so they can finish the job in one sitting.
- Guard the Custom Code section and warn before it can break the storefront - 🟠 Med · effort S
- Today: The Custom Code section (CSS, head scripts, body scripts) is shown to every owner with no warning. A non-technical owner who pastes the wrong thing there can break their live storefront, and the UI shows no caution or recovery path.
- Change: Collapse Custom Code behind an ‘Advanced - for developers’ disclosure that is closed by default, with a plain-language note: ‘These boxes are for code from a developer or an analytics tool (like Google Analytics). Pasting the wrong thing here can break how your store looks. If you’re not sure, leave these empty.’ Keep the existing fields unchanged underneath.
- Why it helps the owner: An owner exploring theme settings today can stumble into raw code boxes and accidentally paste something that breaks their storefront; instead the section is clearly fenced off as developer-only and they are warned before touching it, so the 90% who never need it are never at risk.
- Show whether a page is live to customers in the Delete Page confirmation, and protect against a missing preset - 🟠 Med · effort M
- Today: Two silent gaps: (1) the Delete Page modal names the page and warns ‘cannot be undone’ but never says whether the page is currently published and visible to shoppers, so an owner can’t tell if deleting will immediately break a link customers are using. (2) Only ‘Default’ and ‘Aurora’ presets exist; if a store still has a retired preset id (‘elegant’/‘classic’) saved, the storefront silently loads no preset CSS with no visible signal in Theme Settings.
- Change: In the Delete Page confirmation, add a line driven by the page’s status: if published, ‘This page is LIVE - customers can see it now. Deleting it will immediately show a Not Found error to anyone visiting its link.’; if draft, ‘This page is a draft and is not visible to customers.’ Separately, in Theme Settings, if the saved preset id is not one of the registered presets, show an inline amber notice on the Preset section: ‘Your saved theme is no longer available, so your store is using the basic style. Pick a theme below to fix this.’ and default the selection to Default.
- Why it helps the owner: An owner deleting an old page today can’t tell whether they’re about to break a link customers are actively visiting; instead the modal tells them plainly whether it’s live. And an owner whose store quietly lost its theme styling sees a clear ‘pick a theme to fix this’ prompt instead of an unexplained plain-looking storefront.
Storefront Cart & Checkout
- Warn the owner in the admin when their store has no payment method (and link to fix it) - 🔴 High · effort M
- Today: If no payment gateway is connected, checkout silently shows customers an amber box: “The store hasn’t set up any payment options yet. Please contact the store owner…” (frontstore/app/components/checkout/CheckoutPayment.vue:10-11). The owner gets NO warning anywhere in admin - no banner on the dashboard, store settings, or theme editor. They only find out when a customer complains they can’t pay. The box also has no link, violating CLAUDE.md’s ‘guide to where to configure’ rule.
- Change: Two coordinated changes. (1) In the admin: add a persistent amber alert on the Store Settings page header and the dashboard Action Center - “Customers can’t pay yet: no payment method is connected. Connect Stripe, Razorpay, or enable Cheque / Local Pickup → Integrations” linking to the absolute Integrations path. (2) In the storefront box (CheckoutPayment.vue): when the viewer is the logged-in store owner, swap the dead-end copy for the same actionable line with a link to Integrations; keep the polite ‘contact the store owner’ copy for customers.
- Why it helps the owner: A store owner who just finished setting up products and shared their store link today has no idea their store can’t take a single order - they discover it from an angry customer. Instead they’d see a clear red flag the moment they open admin, with a one-click path to connect payments before they lose a sale.
- Replace developer terms ‘single-page / accordion / multi-step’ with plain labels + a one-line preview of each - 🔴 High · effort S
- Today: The checkout layout picker in Theme Editor (nuxt/…/theme.vue:541-542) is a bare dropdown of raw values [‘single-page’, ‘multi-step’, ‘accordion’] with no description. ‘Accordion’ and ‘multi-step’ are developer words a print-shop owner cannot picture, so they either leave the default blindly or pick at random without understanding what their customers will see.
- Change: Change the options to plain labels with a one-sentence description each: ‘All on one page - fastest, everything visible at once (recommended)’, ‘Step by step - guides the customer through a few short pages’, ‘Collapsible sections - one section open at a time, tidy on phones’. Render them as labelled radio cards (or a select with help text), not raw kebab-case strings. Keep the stored values unchanged so nothing breaks.
- Why it helps the owner: A store owner deciding how their checkout should feel today is shown three meaningless engineering words; instead they read a plain description of what each does and can choose confidently - e.g. picking step-by-step because most of their buyers are on phones.
- Gather all checkout controls into one ‘Checkout’ settings area instead of splitting them between Features and Theme Editor - 🔴 High · effort M
- Today: Checkout behaviour is scattered: guest checkout, request-a-quote, shipping requirement, and contact mode live under Store Settings → Features (settings.vue, alongside reviews/B2B/uploads), while checkout layout style, free-shipping threshold, and option preview limit live under Theme Editor → Cart (theme.vue). An owner who wants to ‘set up checkout’ has no single place to go and must hunt across two different admin pages.
- Change: Create one ‘Cart & Checkout’ settings group (a section or sub-tab) that surfaces all of these in one scroll: guest checkout, request-a-quote, login requirement, shipping requirement, contact mode, layout style, free-shipping threshold, and option preview limit. The layout/threshold fields can remain theme-backed under the hood, but present them together here so the owner sees every checkout decision in one view.
- Why it helps the owner: A store owner setting up checkout for the first time today has to know that guest checkout is under ‘Features’ but layout is under ‘Theme Editor → Cart’ - two pages, no signpost. Instead they open one ‘Cart & Checkout’ area and configure everything about how customers buy in a single sitting.
- Default guest checkout ON so first-time buyers aren’t silently turned away - 🔴 High · effort S
- Today: Guest checkout (quick_order_enabled) defaults to false in the backend config, while the theme defaults object (theme.vue:1229) sets guest_checkout: true - a confusing split-brain default. The effective behaviour for most owners is that a new store forces account creation, so a first-time buyer who just wants one batch of business cards hits a login wall and abandons. The owner never chose this and gets no nudge that it’s costing them sales.
- Change: Make guest checkout ON by default for new stores (align the backend quick_order_enabled default to true, matching the theme default that already says true), and resolve the two defaults to a single source of truth. Add a short helper line under the toggle: ‘Off means customers must create an account before buying - this turns away many first-time shoppers.’
- Why it helps the owner: A store owner who assumes ‘of course people can just buy’ today unknowingly forces sign-up and loses first-time customers with no warning; instead the sensible default lets buyers check out immediately, and the helper text makes the trade-off clear if they ever want to require accounts.
- Make the coupon field reachable on mobile checkout instead of buried in the order-summary drawer - 🟠 Med · effort S
- Today: The coupon input lives only inside CheckoutSummary.vue, which on mobile is collapsed into the ‘Order summary’ drawer at the top of the page. A phone customer holding a promo code the owner advertised has to know to tap ‘Order summary’ to reveal it - many won’t, so the discount the owner promoted never gets applied and they look unreliable.
- Change: On mobile, surface a visible ‘Have a promo code?’ link/field in the main checkout flow (near the totals or place-order button), expanding inline - not hidden behind the summary drawer. Reuse the existing coupon validation logic; only the placement changes so the field is discoverable without opening the drawer.
- Why it helps the owner: A store owner who runs a ‘10% off’ promo today watches mobile customers fail to find where to enter the code and pay full price (or abandon, feeling tricked); instead the code field is right there in the checkout flow, so the discount they advertised actually gets used.
- Turn the ‘Failed to lock designs’ checkout error into plain language with a working retry - 🟠 Med · effort S
- Today: When the pre-submission POST /api/designer/designs/lock-for-checkout step fails, the customer sees a toast: ‘Failed to lock designs. Please try again.’ A non-technical buyer has no idea what ‘locking a design’ means or how to retry meaningfully - and the store owner gets a stuck customer at the very last step, the most expensive place to lose an order.
- Change: Replace the message with plain, action-oriented copy: ‘We couldn’t finish saving your artwork for this order. Please tap Place Order again - if it keeps happening, refresh the page or contact us.’ Make the retry actionable (re-attempt the lock automatically on the next Place Order press rather than leaving the button in a dead state), and surface the store’s contact email/phone if it fails twice so the customer has a path forward.
- Why it helps the owner: A store owner loses a ready-to-buy customer at the final click today because a cryptic ‘lock designs’ error gives the buyer nothing to act on; instead the buyer reads what to do next and can retry or reach the shop, so a near-complete order doesn’t evaporate.
Designer Studio
- Show “Customers can’t personalise this yet” right on the product, with a one-click fix - 🔴 High · effort M
- Today: To let customers personalise a product, the owner must open the product, scroll to the ‘Designer’ accordion in the Settings tab, flip the ‘Enable Designer’ quick-toggle, then dig into the separate ‘Studio’ sub-tab to switch on Mini Studio. Nothing on the product list or product header tells them the designer is currently off, and the Designer section just says ‘Enable the Designer toggle above’ without pointing to which toggle. A non-technical owner has no way to know personalisation is missing or where to switch it on.
- Change: Add a plain-language status line at the top of the product editor and a small badge on each product list row: when enable_designer is false, show ‘Customers can’t personalise this product yet’ with an ‘Turn on online designer’ button that flips enable_designer (and offers to also enable Mini Studio). When it is on, show ‘Customers can personalise this online’ in green. In the Designer section, replace ‘Enable the Designer toggle above’ with an inline ‘Turn on online designer’ button so the owner fixes it in place instead of hunting for the toggle.
- Why it helps the owner: A shop owner who just added ‘Premium Business Cards’ and wants buyers to type their own name and number can see at a glance that personalisation is off and switch it on with one click from the product itself, instead of guessing across two tabs and a sub-tab.
- Replace raw ‘System error’ and the missing ‘message.exportError’ key with plain-language messages and a retry - 🔴 High · effort S
- Today: When a PDF preview export fails, the designer shows the literal i18n key ‘message.exportError’ because that key is missing from designer/src/plugins/i18n/lang/en.ts, and the API error handler in designer/src/utils/request.ts (lines 290 and 336) falls back to the hardcoded string ‘System error’. A customer designing their flyer sees developer text with no idea what to do next, and the shop owner gets a confused support call.
- Change: Add the missing ‘exportError’ and ‘previewPdfReady’ entries to en.ts with shopper-friendly copy, and change the two ‘System error’ fallbacks in request.ts to a plain message like ‘Something went wrong on our end. Please try again in a moment.’ Pair every one of these with a visible ‘Try again’ button that re-runs the export/save, so the customer is never stuck on a dead end.
- Why it helps the owner: A customer finishing their business-card design and hitting a momentary export hiccup sees ‘We couldn’t generate your preview - try again’ with a button, completes their order, and the shop owner never fields a ‘your site is broken’ message about cryptic text.
- Reassure the customer their design is saved before forcing them to log in - 🔴 High · effort S
- Today: On the Design Options page, when a guest clicks ‘Add to Cart’ and quick-order is off, handleAddToCart immediately pops a LoginModal with no explanation. The customer who just spent ten minutes designing has no idea why login is suddenly required or whether the design they just made will survive logging in - a common moment to abandon.
- Change: Before opening the login modal, show one line of reassurance inside it: ‘Your design is saved. Sign in or create a free account to add it to your cart - you won’t lose your work.’ Since the design already has a design_id persisted to the backend, this is true and the modal already supports re-running handleAddToCart on success.
- Why it helps the owner: A first-time customer who designed a custom t-shirt and hits the login wall is told their work is safe and why the account is needed, so they sign up and complete the order instead of closing the tab - directly protecting the shop owner’s sale.
- Rename ‘Material Library’ to ‘Logos & Graphics’ in the Designer Studio hub - 🟠 Med · effort S
- Today: The Designer Studio hub at /setting/designer labels the cliparts card ‘Material Library’ (setting/designer/index.vue line 40). A print-shop owner looking to upload a logo or decorative graphic for customers to drop into designs would not connect ‘Material Library’ to clipart, and the internal category name ‘clipart’ leaks through the URL and API.
- Change: Rename the card title from ‘Material Library’ to ‘Logos & Graphics’ (or ‘Graphics & Cliparts’) with a one-line subtitle ‘Logos, icons and graphics customers can add to their designs.’ Keep the underlying clipart route/API unchanged so nothing breaks; this is a label and description change only.
- Why it helps the owner: A shop owner who wants to make their company logo available inside the designer scans the hub, recognises ‘Logos & Graphics’, and finds it immediately instead of overlooking a ‘Material Library’ card that means nothing to them.
- Tell the customer their design is being prepared instead of showing a blank space - 🟠 Med · effort M
- Today: After saving from the designer, design-options.vue polls up to 30 seconds for the preview image. The DesignPreviewPanel does render a spinner while isPreviewLoading is true, but it only mounts when artworkMethod===‘design’ AND (hasDesignPreview OR isPreviewLoading); if the design has no preview yet and a timing edge leaves isPreviewLoading false, or after the 12-attempt window quietly ends, the left column collapses and the customer sees no preview and no message - reading as ‘my design was lost’.
- Change: Always reserve the preview column when a design_id is present, and make the panel show an explicit ‘Preparing your design preview…’ state during polling and a ‘Preview is taking a little longer - your design is saved, you can keep configuring your order’ message (with a manual ‘Refresh preview’ button) if the 30-second poll ends without an image, instead of silently stopping.
- Why it helps the owner: A customer who configures size and quantity while their preview renders is never faced with an empty box that looks like their design vanished, so they trust the order and the shop owner avoids ‘where did my design go’ complaints.
- Warn the admin before they lose an unsaved template when the designer tab is closed - 🟠 Med · effort M
- Today: Creating a template opens the full designer SPA in a new browser tab carrying admin_token/template_id/return_url, with no in-context instruction. If the owner accidentally closes that tab before saving, the template silently doesn’t save and they get no warning - they return to the template list assuming their work was kept.
- Change: In the designer SPA, add a beforeunload guard that prompts ‘You have unsaved design changes - leave anyway?’ when the canvas has unsaved edits, and on the template list page show a short inline note when launching (‘Your template opens in a new tab. Click Save before closing it.’). On return via return_url, show a clear ‘Template saved’ confirmation so the owner knows it worked.
- Why it helps the owner: A shop owner building a flyer template who closes the wrong tab is stopped and reminded to save, instead of losing twenty minutes of layout work and having no idea why their template never appeared in the list.
AI Product Builder
- After AI creates the product, ask ‘Add to your store now?’ instead of leaving it invisible to customers - 🔴 High · effort M
- Today: AiProductCreatorService creates the Product with status=‘active’ but never inserts a store_products pivot row, so the product is invisible on the storefront. The result screen’s action card (product-builder.vue lines 305-333) says only ‘Edit the product to add images, adjust pricing, set up rules, and publish it’ and never mentions store assignment. A non-technical owner sees ‘Product created successfully’, assumes it’s live, and is baffled when customers can’t find it.
- Change: On the result screen, add a primary ‘Add to my store’ button (or a checkbox ‘Make this visible on my storefront’ defaulted on) that calls the existing store_products assignment endpoint for the owner’s primary store. Until it’s assigned, show a plain-language status pill on the result card: ‘Saved to your catalog, but not on your website yet.’ Once assigned, change it to ‘Live on [Store Name]’. Wire the same assignment into AiProductCreatorService::create() as the default for single-store tenants.
- Why it helps the owner: A store owner who just described ‘Black hoodie S to 2XL’ and saw ‘created successfully’ today must somehow know to open the editor, scroll to a ‘Stores’ section, tick their store, and re-save before any customer can buy it — with no hint that step exists. Instead they click one ‘Add to my store’ button (or it’s pre-ticked) and the hoodie is actually for sale.
- Replace the developer ‘AI confidence: 73%’ line with a plain next-step the owner can act on - 🔴 High · effort S
- Today: The success banner (product-builder.vue lines 171-174) prints ‘AI confidence: {{ result.confidence }}%’ next to the AI’s raw reasoning sentence. A print-shop owner has no mental model for an ‘AI confidence score’ and no idea what to do if it reads 35%. Worse, the create threshold is only 30, so genuinely shaky products (confidence 30-50) are created and presented with the same celebratory green banner as a confident 95.
- Change: Drop the raw percentage from the UI. Map confidence to a plain verdict: >=70 -> green ‘Looks complete — review and add your artwork’; 40-69 -> amber ‘I made some guesses — please double-check the print size and options before publishing’; 30-39 -> amber ‘I wasn’t very sure about this one. Check the size, options, and price carefully.’ Keep the numeric value only in the AI Usage log, where it’s a diagnostic for support.
- Why it helps the owner: An owner who gets back a 38%-confidence product today sees a green ‘success’ badge and a number that means nothing, so they publish a half-wrong product. Instead they see ‘I wasn’t very sure — check the print size before publishing’ and know exactly what to verify.
- Make the result screen show the store’s real currency and humanise the type and pricing labels - 🔴 High · effort M
- Today: AiBuilderResultResource line 76 hardcodes ‘currency’ => ‘USD’, and product-builder.vue lines 203 and 294 hardcode a ’$’ symbol — so a GBP or INR shop sees the wrong currency on every AI result. The type badge (line 193) renders the raw enum (‘ecommerce’,‘hybrid’) with CSS capitalize, and the pricing label (line 296) shows ‘pricing_suggestion’ with only one underscore replaced (‘hybrid_area_quantity’ -> ‘hybrid area quantity’). All three are developer values leaking to a non-technical owner.
- Change: Return the store’s configured currency code from the resource (use the store currency already threaded elsewhere) and format both prices through the admin money formatter instead of a literal ’$’. Map the type enum to plain labels: printing->‘Print Product’, tshirt->‘Apparel’, ecommerce->‘Merchandise’, service->‘Service’, hybrid->‘Print + Merchandise’. Map pricing_suggestion to a sentence: quantity_based->‘Price changes with quantity’, area_based->‘Price changes with print size’, hybrid_area_quantity->‘Price changes with size and quantity’, flat->‘Single fixed price’.
- Why it helps the owner: An owner running a UK shop who creates an A4 poster today sees ‘$12.00’ and a badge reading ‘Hybrid’ — neither matches their world. Instead they see ‘£12.00’, ‘Print + Merchandise’, and ‘Price changes with size and quantity’, and can trust what they’re reading at a glance.
- Surface the auto-applied 60/40 cost-and-margin split on the result, instead of setting it silently - 🟠 Med · effort S
- Today: AiProductCreatorService hardcodes cost_price = base_price x 0.6 and profit_margin = 40 for every product, and nothing on the result screen tells the owner this happened. The pricing card (lines 289-301) shows only the base price. An owner whose real material cost is, say, 80% of price is now carrying a wrong margin in their books and has no idea it was invented for them.
- Change: In the pricing card, show a small editable summary: ‘Estimated cost: [cost] · Your profit: [margin]%’ with helper text ‘We estimated this — update it to match your real costs.’ and an inline link to the pricing section of the editor. Keep 60/40 as the default, but make it visible and one click to correct.
- Why it helps the owner: A shop owner whose hoodies cost more than the AI assumed has, today, a silently wrong profit margin that only surfaces when their numbers don’t add up months later. Instead they see ‘We estimated your cost at X — update it’ right on the result and fix it in seconds.
- Add an ‘AI Product Creator’ entry point on the Products list page, where owners actually go to add products - 🟠 Med · effort S
- Today: The only entry point is Header.vue’s Catalog -> AI dropdown section (line 181); the Products list page has no mention of it. A non-technical owner trying to add a product opens the Products page and looks for an ‘Add’ button — ‘AI’ in a dropdown is not where their intuition sends them, so the feature goes undiscovered.
- Change: On the Products list page, next to the existing ‘Add Product’ button, add a secondary ‘Create with AI’ button (sparkles icon) linking to /ai/product-builder. On the empty state of the Products list, feature it prominently: ‘Describe a product and let AI set it up for you.’
- Why it helps the owner: An owner with zero products today stares at an empty Products list and a single manual ‘Add Product’ form, never realising they could just type ‘Black hoodie S to 2XL’. Instead the AI option sits right where they’re already standing, at the moment they’re trying to add a product.
- Make the setup wall self-explanatory and tie the loading bar to real progress - 🟠 Med · effort M
- Today: Two separate gaps hit non-technical owners. (1) The not-ready banner (lines 34-47) just shows the API message plus a ‘Settings -> Integrations’ link, with no plain-language explanation of what Anthropic/OpenAI is, where to get a key, or roughly what it costs — most owners stop here. (2) The loading animation (startAnimation, lines 382-390) advances on a fixed 2s timer to 90% and the final ‘Save’ step never checks until the call returns; if the AI takes longer than ~8s the owner sees a stalled bar and assumes it broke.
- Change: (1) Replace the bare banner with a short plain-language card: ‘This feature uses an AI assistant (like ChatGPT or Claude) to set up products for you. You’ll need a free account key from one of them — here’s how (2 minutes).’ with a step-by-step link, so the owner isn’t dropped onto an integrations page cold. (2) Keep the orb animation but cap the fake progress at ~85% and, once it lingers, swap the caption to a calm ‘Still working — complex products can take a few more seconds…’ so a slow call never reads as a freeze; jump to 100% only on the real response.
- Why it helps the owner: An owner who’s never heard of ‘Anthropic’ hits a red banner today and quits before typing anything; and an owner with a detailed prompt watches a bar freeze at 90% and wonders if it crashed. Instead they get a plain explanation of what to connect and why, and a loading state that reassures rather than alarms when the AI takes its time.
PDF / Artwork Pipeline
- Stop the Print File Checker from looking broken when its server flag is off - 🔴 High · effort S
- Today: /ai/preflight (nuxt/app/pages/ai/preflight.vue) renders a fully polished drag-and-drop UI with an active ‘Check File’ button, but every check returns the raw 503 ‘Preflight checking is not enabled on this server’ because PDF_SERVICE_FEAT_PREFLIGHT is absent from .env and defaults to false. A shop owner who finds this tool, drags in a file, and hits that developer error will conclude the whole product is broken - there is no explanation, no ‘not available’ label, and no link to fix it.
- Change: On page load, call a lightweight capability probe (e.g. reuse pdf-service:status, or have the controller return a ‘preflight available: false’ health flag) BEFORE showing the drop zone. When unavailable, replace the upload card with the project’s standard amber ‘integration not configured’ panel: heading ‘Print File Checker isn’t switched on yet’, plain text ‘This tool needs your print-file service turned on by your administrator before it can check files.’, and a link to the PDF service settings/status page. Hide or disable the ‘Check File’ button entirely (with a tooltip stating why) rather than letting it fire and 503. When available, show the existing UI unchanged. Never surface the raw 503 string to the user.
- Why it helps the owner: A store owner who opens the Print File Checker today uploads a file and gets a cryptic ‘not enabled on this server’ error, leaving them thinking the feature - and possibly the platform - is broken. Instead they would immediately see a clear note that the tool just needs switching on, plus exactly where to go, so they never waste time or lose trust.
- Show a print-readiness badge on customer-uploaded files in the order detail - 🔴 High · effort M
- Today: In nuxt/app/pages/orders/[id].vue (lines ~1037-1047) the ‘Customer Files’ section renders each uploaded artwork file as a bare download link labeled by filename or ‘Artwork file’, with zero quality information. Files up to 200MB are accepted at upload with no DPI/color-mode/size check (StorefrontArtworkUploadController is Laravel-only, no processing). The owner only discovers a 72 DPI RGB or wrong-size file after downloading it and opening it in their print software - at which point the order may already be in production.
- Change: When the preflight/pdf-service is available, run a check on each uploaded file (asynchronously at upload time, or on-demand from the order page) and render a small status badge next to each file row in the Customer Files list: green ‘Print-ready’, amber ‘Check before printing - low resolution / RGB’, red ‘Not print-ready - too low resolution’. Make the badge click-through to the full preflight report. When the service is off, show a neutral ‘Quality not checked’ chip instead of nothing, so the absence is explicit rather than silently implying the file is fine.
- Why it helps the owner: A shop owner processing an order today has to download every customer file and open it in Illustrator/Acrobat just to find out one is too low-res to print - often after they’ve already started the job. Instead they would glance at the order screen, see a red ‘Not print-ready’ badge, and email the customer for a better file before any plates or ink are wasted.
- Label the designer ‘Download PDF’ output by quality and warn when it is web-grade - 🔴 High · effort M
- Today: When PDF_SERVICE_FEAT_PDF_EXPORT is false (its production default), the designer’s ‘Export’ / ‘Download PDF’ button silently routes through legacyExportFile() and produces a 72-96 DPI RGB dompdf file. Nothing in the UI distinguishes this from the 300 DPI CMYK Node.js output. A customer or owner downloads it, sends it to a printer, and gets a blurry web-quality result with no warning it was unsuitable.
- Change: Have exportFile() return which path produced the file (legacy web-grade vs print-grade) and reflect it in the download UI. For print-grade output, label the button/result ‘Download Print-Ready PDF (300 DPI)’. For the legacy path, either relabel to ‘Download Preview PDF (web quality - not for printing)’ and surface a one-line note ‘This file is for on-screen preview only. For a print-ready file, ask the store to enable high-quality export.’, or, preferably, point the customer-facing ‘Download PDF’ action at the existing high-quality previewPdf Node.js endpoint so customers always get print-grade output. Never hand over a web-grade PDF that visually claims to be the deliverable.
- Why it helps the owner: A store owner who tells a customer ‘just download your design as a PDF and we’ll print it’ is today handing out blurry 72 DPI files that look fine on screen and fail on press, generating reprints and complaints they can’t explain. Instead the file is clearly labeled by quality - or is genuinely print-ready - so what the customer downloads is what the press can actually use.
- Turn the designer preview 503 into a recoverable, explained state - 🟠 Med · effort S
- Today: The previewPdf endpoint (behind ‘Update Preview’ / ‘Download PDF’) always calls the Node.js pdf-service with no fallback, and on outage returns HTTP 503 ‘PDF service is currently unavailable. Try again in a moment.’ shown as a bare toast. The user gets no retry control, no indication whether it’s temporary, and the preview area is left in whatever state it was - reading as ‘broken’ for a non-technical owner.
- Change: In the designer, catch the 503 specifically and render an inline recovery state in the preview slot (not just a toast): ‘We couldn’t build your print preview right now. This is usually temporary.’ plus a visible ‘Try again’ button that re-fires the request, and an auto-retry with a short countdown (‘Retrying in 5s…’). Reserve the preview space so the layout doesn’t jump. Distinguish a transient outage (offer retry) from a hard misconfiguration (after N failures, escalate to ‘If this keeps happening, the store’s print service may need attention’ with a settings link for admins).
- Why it helps the owner: When the print service hiccups, a shop owner clicking ‘Update Preview’ today just sees a red error flash and a frozen panel, with no idea if they did something wrong or whether to keep trying. Instead they’d see a calm ‘temporary, retrying…’ message with a Try Again button, so a momentary blip doesn’t read as the designer being broken.
- Let store owners customise invoice/quote/proof PDFs without touching code - 🟠 Med · effort L
- Today: Invoice, quote, order, and proof PDFs are rendered by dompdf from hard-coded Blade templates (InvoiceService::generateInvoicePdf, ProofService, OrderService). Logo size, accent color, and footer text (terms, return policy, contact line) are fixed in code, so a non-technical owner has no way to brand or adjust the documents their customers receive.
- Change: Expose a small ‘Document Appearance’ section in store settings that feeds the existing Blade templates: upload/scale logo, pick an accent color, and edit footer/terms text, with a live ‘Preview Invoice’ button that renders a sample through the same dompdf path. Wire these as template variables into the existing invoice/quote/proof Blade views - no new PDF engine, just data-driven values with sensible defaults pre-filled from existing store info so it works out of the box.
- Why it helps the owner: A shop owner who wants their invoices and proofs to carry their logo and ‘Payment due in 14 days’ footer today simply can’t - the documents are locked in code and they’d have to file a support request. Instead they could upload their logo, set their accent color, and type their terms once, and every customer-facing PDF reflects their brand.
- Confirm imposition render failures surface a plain-language status in Job Watch - 🔵 Low · effort S
- Today: The imposition flow returns asynchronously via a Node.js callback; on render failure the ImpositionController logs the error and POSTs a JSON body, but it is unconfirmed whether the /imposition Job Watch UI (which polls every 15s) shows the owner a readable failure. If it leaves a stuck/blank status, a production-staff user waits indefinitely on a press sheet that will never arrive.
- Change: Ensure the callback’s error case maps to a distinct ‘failed’ status that Job Watch renders as a clear red row: ‘Imposition failed to render - please try again or check the source files.’ with a ‘Retry’ action, instead of leaving the job spinning or blank. Stop the 15s polling once a terminal failed/done status is received so the row doesn’t appear to hang forever.
- Why it helps the owner: A production user who kicks off a gang-run imposition and walks away today may come back to a job that silently never finished, with no way to tell if it’s still working or dead. Instead they’d see a clear ‘failed - retry’ status the moment it breaks, so they can re-run it rather than waiting on output that’s never coming.
Dashboard & Reporting
- Make the three Reports buttons actually do something - or remove them - 🔴 High · effort S
- Today: In nuxt/app/components/dashboard/ReportsWidget.vue the ‘Sales Report’, ‘Customer Report’, and ‘Inventory Report’ buttons spin for a fake 1-second setTimeout, then render as clickable rows with a › chevron, empty icon strings, no @click, no to=, and no route. They go nowhere. A non-technical owner sees three official-looking report buttons, clicks one expecting a report, and nothing happens - no page, no message, no ‘coming soon’. This reads as a broken product on the very first screen after login.
- Change: Wire each button to the data the dashboard already aggregates instead of inventing new report pages. ‘Sales Report’ → nuxt-link to a pre-filtered /invoices?status=paid view (or /orders) which already exists; ‘Customer Report’ → /web-leads or the customers list; ‘Inventory Report’ → the products/stock list. Replace the fake setTimeout with real navigation. Fill the empty icon fields with real heroicons (i-heroicons-banknotes, i-heroicons-users, i-heroicons-cube). If a real destination genuinely does not exist for one of them, do not render that button at all (gate it) rather than shipping a dead row.
- Why it helps the owner: A store owner who wants to see ‘how did sales go this month’ today clicks Sales Report and is met with silence, concluding the feature - and possibly the product - is broken. Instead, one click takes them straight to their paid invoices, the exact data they were after, turning three dead buttons into three real shortcuts.
- Give every metric widget a real error and empty state instead of an eternal spinner - 🔴 High · effort M
- Today: InvoiceWidget, QuoteWidget, MetricTabs, PaperTrailWidget, and WebLeadsWidget all handle a failed API call with only console.error(). On failure InvoiceWidget.vue sets loading=false but progressItems stays empty, so the owner sees an empty table with column headers and no rows; MetricTabs shows zeros. There is no message, no retry, and no signal that something went wrong - a blank widget is indistinguishable from ‘you have no data’.
- Change: Add an explicit error ref to each widget’s catch block. When the call fails, render a plain-language inline message with a Retry button, e.g. ‘We couldn’t load your invoice summary. Check your connection and try again.’ with a button that re-runs the fetch. When the call succeeds but returns zero rows, show a true empty state (‘No invoices yet - they’ll appear here once you create your first one.’), not an empty table. Distinguish loading vs error vs empty as three separate states per the CLAUDE.md mandate.
- Why it helps the owner: A store owner glancing at the dashboard to check ‘are any invoices unpaid’ currently can’t tell whether an empty Invoices widget means everything is settled or the data failed to load - a dangerous ambiguity for money. Instead they get either a clear ‘all clear, nothing yet’ or a ‘this failed, tap to retry’, so they always know which is true.
- Fix the Top Products chart to show the store’s real currency, not a hardcoded dollar sign - 🔴 High · effort S
- Today: TopProductsBarChart.vue hardcodes the Y-axis title to ‘Revenue ($)’ and formats every value as ’$’ + value (lines 132 and 135), and the tooltip prepends ’$’ too. A shop owner configured in GBP or EUR sees pounds of revenue labelled with dollar signs, while the revenue KPI card directly above it (DashboardOrderRevenueWidget) correctly uses CurrencyHelper::settingsForStore(). The same screen shows two different currencies for the same money.
- Change: Pass the store currency symbol into the chart the same way the revenue widget gets it (CurrencyHelper / store settings), and replace the three hardcoded ’$’ occurrences - Y-axis title text, the yaxis labels formatter, and the custom tooltip - with that symbol. Also reword the Y-axis title from the technical ‘Revenue ($)’ to plainly ‘Revenue (£)’ etc., matching the card above.
- Why it helps the owner: A UK or EU shop owner reviewing their best-selling products today sees dollar signs on a chart for money they bank in pounds, making them distrust every number on the dashboard. Instead the chart speaks their currency, matching the card right above it, so the whole financial picture reads as one consistent, trustworthy view.
- Rename ‘Overview’ and either make the tab selection do something or drop it - 🟠 Med · effort M
- Today: MetricTabs.vue titles the panel ‘Overview’ (line 8), which tells a non-technical owner nothing. Worse, clicking the Design Proofs / Website leads / Quotes tabs only flips a local selectedMetric ref (lines 25/33/45) that drives nothing visible - the LazyDashboardTopProductsBarChart below never reacts to the selection. The tabs look interactive and selectable but selecting one changes nothing, so the control feels broken.
- Change: Rename the header from ‘Overview’ to something a shop owner understands at a glance, e.g. ‘Where your work is coming from’ or simply ‘New This Week’. Then resolve the dead selection one of two ways: either make selecting a tab actually re-scope the chart/content below it, or remove the selected-state styling entirely so the three cards read as plain navigational stat tiles (which is what they functionally are - each links to its list page). Do not leave a toggle that visibly highlights but changes nothing.
- Why it helps the owner: A store owner clicking ‘Quotes’ at the top of their dashboard expecting the view to focus on quotes today gets a highlighted tab and zero change, making them think the screen is frozen. Instead the header tells them what they’re looking at in plain words, and the cards behave honestly - either filtering the view or simply jumping to the quotes list.
- Clarify the ‘Design Proofs’ card so zero doesn’t read as ‘no proofs’ - 🟠 Med · effort S
- Today: The Design Proofs stat card in MetricTabs counts only unlinked Proof records (proofable_type/proofable_id IS NULL), but the label just says ‘Design Proofs’ with no qualifier. An owner who has many proofs attached to jobs but none unlinked sees ‘0’ and reasonably concludes they have no design proofs at all - the number means something narrower than the label promises.
- Change: Either change the label to describe what is actually counted (e.g. ‘Unassigned Proofs’ or ‘Proofs awaiting a job’) with a small tooltip ‘Design proofs not yet linked to an order or job’, or change the underlying count to match the broad label and total all proofs. Pick whichever matches what an owner most needs to act on; if the intent is ‘proofs that need attention’, the ‘Unassigned Proofs’ label plus tooltip is the honest fix.
- Why it helps the owner: A shop owner checking whether any artwork still needs sorting sees ‘0 Design Proofs’ and assumes nothing is outstanding, when in fact several unassigned proofs may be sitting unhandled. A precise label plus tooltip means the number finally matches what they think it means, so nothing slips through.
- Tell mobile users that dashboard customisation exists (on a bigger screen) - 🔵 Low · effort S
- Today: Drag-to-rearrange is fully disabled below 768px with the drag handles simply hidden and no explanation. A shop owner running their business from a phone has no idea the board is customisable at all, and an owner who customised it on desktop may be confused why it won’t budge on mobile.
- Change: On mobile, show a one-line, dismissible hint near the dashboard heading - e.g. ‘Tip: open your dashboard on a computer to rearrange these panels.’ Keep it subtle and store the dismissal in localStorage like the setup checklist already does, so it appears once and goes away.
- Why it helps the owner: A store owner who lives on their phone never discovers they can tailor their dashboard, or fights a board that won’t drag, with no clue why. A single plain-language tip tells them the feature exists and where to use it, instead of leaving the capability invisible.