Commit Graph

13 Commits

Author SHA1 Message Date
louispaulb
f37b1d2803 fix(campaigns/templates): clarify who refunds whom for early cancellation
User feedback: the prorata disclaimer was semantically wrong — both FR
("le prorata du montant est remboursable") and EN ("we'll refund the
pro-rated amount") read as if TARGO would refund the customer, when
actually the customer needs to refund the unused portion of the gift
they received if they cancel within the commitment period.

Plus: add the explicit per-month rate ($20/month at $60 / 3 months) so
the customer knows exactly what they'd owe at any cancellation date.

FR:
  before — "🪂 En cas de départ avant {{commitment_months}} mois,
            le prorata du montant est remboursable."
  after  — "🪂 Si tu résilies avant {{commitment_months}} mois,
            tu rembourses le prorata non utilisé (20 $/mois)."

EN:
  before — "🪂 If you decide to leave before {{commitment_months}}
            months, we'll refund the pro-rated amount."
  after  — "🪂 If you cancel before {{commitment_months}} months,
            you refund the unused pro-rated amount ($20/month)."

Both changes:
  • Subject clarified: customer refunds, not TARGO
  • Added explicit per-month value for transparency
  • Kept warm tone (informal "tu" / "you")
  • Mustache {{commitment_months}} preserved

Applied directly to .html + .json via string substitution (preserves
the Unlayer design tree intact except for that one phrase). The
"$20/month" figure is hardcoded for the current $60/3-month campaign;
a future {{monthly_prorata}} computed variable would generalize but
isn't needed yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:39:01 -04:00
louispaulb
2c47d3269e feat(campaigns/translate): switch from literal to copywriter-mode AI prompt
User feedback: previous prompt produced robotic word-for-word output that
lost the marketing impact. The system prompt was too restrictive on
preservation, suppressing Gemini's natural rephrasing ability.

Rewrote the system prompt with three structural changes:

1. FRAME shifted from "translator" to "senior marketing copywriter"
   The opening line now says "You are NOT translating words — you are
   rewriting marketing copy that lands the same way for a different
   audience." This unlocks idiomatic rephrasing, sentence reorganization,
   active/passive switching, and cultural metaphor adaptation.

2. FEW-SHOT EXAMPLES showing the desired style
   4 FR→EN pairs in the prompt itself, with explicit "NOT: <literal>"
   anti-examples to show what to avoid:
     • "loyauté envers l'achat local" → "keeping it local"
       (not "loyalty to local shopping")
     • "connexions stables et relations durables" →
       "steady connections — both the fiber kind and the human kind"
       (not "stable connections and lasting relationships")
     • "On est juste à côté" → "We're right next door"
     • "Avec l'arrivée de l'été" → "Summer's here"
   These ground Gemini in the brand voice with concrete examples.

3. TONE constraint explicit
   "Warm, conversational, slightly playful. Like a neighbor explaining
   something — never corporate, never stiff." Use of contractions
   ("we're", "you'll") encouraged.

Plus: temperature bumped from 0.2 → 0.7 so Gemini actually exercises
creative rephrasing instead of staying glued to source word order.

Structural preservation rules (HTML, Mustache vars, brand names, emojis,
URLs, technical values like "3.5 Gbit/s") kept as HARD CONSTRAINTS but
clearly separated from the creative freedom on text content.

Live re-translation of gift-email-fr → gift-email-en applied:
  • 51s response time (similar to literal version)
  • 35,934 → 36,067 bytes (slight expansion, normal for EN)
  • Output markers confirm idiomatic phrasing landed:
    "Thanks for keeping it local", "steady connections — both kinds",
    "right next door", "lending a hand", "Summer's here"
  • Mustache vars + brand names + HTML preserved (verified)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:29:16 -04:00
louispaulb
d716e69ef6 feat(campaigns/templates): mirror user's FR edits to EN + drop legacy .mjml
User edited gift-email-fr in the Unlayer editor with richer marketing
copy (loyalty thanks, brand manifesto, 3.5 Gbit/s upsell, helpful CTA).
Mirror those edits to the EN template via a one-shot translation script
so the bilingual pair stays in sync for the next campaign send.

Translation strategy: plain-string find/replace mapping with FR
phrases in longest-first order to avoid partial matches. Applied to
BOTH the rendered .html (what the recipient sees) AND the .json
(Unlayer design tree — so re-opening the EN editor preserves the
matching structure).

Mapping coverage:
  • Intro paragraphs (greeting, gift announcement, loyalty thanks,
    brand manifesto, speed upsell, "we're around the corner")
  • Offer info pill (amount, instant activation, commitment)
  • CTA button labels (Activer → Redeem, Choisir → Pick)
  • Prorata refund disclaimer
  • Option 2 "do nothing" text
  • Signature ("Merci de faire rouler" → "Thanks for helping...thrive")
  • Footer contact info + "Tous droits réservés" → "All rights reserved"
  • <html lang="fr"> → <html lang="en">

23/28 translation rules matched; the 5 unused ones were for legacy
phrasing not present in the user's latest save (e.g. the old "Tu
choisis local" line that was replaced by the current intro).

Also: drop the obsolete .mjml source files. Now that Unlayer is the
canonical editor, the MJML→HTML compile pipeline is no longer used
on save (Unlayer outputs HTML directly). The .mjml files were stale
copies from the previous MJML-based editor. Removed from disk on
prod and from git history; rollback via git revert if needed.

Verified live: GET /campaigns/templates/gift-email-en returns the
translated content (9 EN markers detected in HTML). Test-send to
louis@targo.ca queued via Mailjet for visual QA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:07:05 -04:00
louispaulb
448e62177e feat(campaigns): convert existing HTML templates to Unlayer JSON designs
Solve the "editor starts blank" problem by writing a one-time converter
that wraps each compiled .html template into a minimal Unlayer design
JSON (one Custom HTML block containing the entire body content). On
next editor load, Unlayer reads .json and renders the template in the
canvas — instant visual fidelity without manual reconstruction.

Strategy choice: Unlayer's "Import HTML" is a Pro-only feature. Building
a real HTML→Unlayer-blocks parser is several days of work. The minimal-
viable conversion (1 row + 1 Custom HTML block) gets the user 90% there
immediately:

  • Canvas shows the template visually (Unlayer renders the HTML)
  • Variables ({{firstname}}, {{gift_url}}, etc.) preserved as text
  • User can edit the HTML directly via the block's side panel
  • User can incrementally REPLACE the HTML block with native Unlayer
    blocks (Text, Image, Button) for any section they want decomposed —
    on their own schedule, not blocking the campaign send

New file: services/targo-hub/scripts/convert-html-to-unlayer.js
  • CLI: node scripts/convert-html-to-unlayer.js <template-name>
  • Reads templates/<name>.html, extracts <body> inner content, detects
    preheader from a hidden <div style="display:none">, builds Unlayer
    design JSON with brand-appropriate body.values (Targo Green link
    color #00C853, Plus Jakarta Sans font, F5FAF7 page background).
  • Backs up existing .json before overwriting.

Generated outputs (committed):
  templates/gift-email-fr.json — 34 KB (30 KB inner HTML + Unlayer chrome)
  templates/gift-email-en.json — 33 KB

Live verification: GET /campaigns/templates/gift-email-fr now returns
{ design: {...Unlayer JSON...} } alongside html. The editor's
onReady() callback in TemplateEditorPage detects data.design and calls
editor.loadDesign(design) → canvas populated immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:22:47 -04:00
louispaulb
2fe8d3f50e feat(campaigns/templates): richer 4-block intro (greeting, hook, gift, upsell)
Expanded the email intro from 3 short paragraphs into 4 semantic blocks,
restoring the marketing-friendly "Tu choisis local..." line that earlier
edits had dropped, plus adding new content about the 3.5 Gbit/s plans
and a "we're right around the corner" CTA framing.

FR intro structure now:
  1. "Bonjour {{firstname}},"
  2. "Tu choisis local, on veut te remercier. / Comme toi, on aime les
     connexions stables et les relations durables." (paired manifesto)
  3. "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible
     pour un temps limité."
  4. "Nous offrons maintenant de nouveaux forfaits, jusqu'à 3.5 Gbit/s.
     Que tu souhaites plus de vitesse, battre une autre offre ou juste
     nous jaser, on est juste à côté."

EN translation mirrors the same 4-block structure.

Editorial rationale for block grouping in MJML:
- Each block is its own <mj-text> for independent drag-drop in GrapesJS
- Lines that always travel together (manifesto pair, upsell + CTA pair)
  share one <mj-text> joined with <br/> to reduce component clutter
- Different styles per block (greeting smaller/secondary, manifesto
  larger/bolder, body paragraphs normal) require separate <mj-text>
  components anyway since MJML inherits styling per-block

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:44:16 -04:00
louispaulb
79ae38db60 feat(campaigns): MJML canonical templates + test-send button
Two big moves:

1. Promote MJML to the canonical template format
   - Move gift-email-fr-mjml.{mjml,html} → gift-email-fr.{mjml,html}
   - Create gift-email-en.mjml (English translation of FR MJML)
   - Compile EN MJML → gift-email-en.html
   - Remove obsolete variants:
     • gift-email-fr-simple.html (now replaced by MJML)
     • gift-email-en-simple.html (same)
     • gift-email-fr-mjml.* (renamed to canonical)
   - The old gift-email-fr.html (rich-with-merchant-grid version) is
     backed up as gift-email-fr.legacy-rich.html.bak — kept on disk
     for reference but not in the editable list.
   - EDITABLE_TEMPLATES is now just ['gift-email-fr', 'gift-email-en'],
     both backed by .mjml source + .html auto-compiled output.

2. Add "Envoyer un test" feature
   Backend:
   - POST /campaigns/templates/:name/test-send accepts { to, vars,
     from?, subject? }. Reads compiled .html, renders Mustache vars,
     sends via Mailjet through email.sendEmail with X-MJ-CustomID
     "test-send:<name>:<timestamp>" so webhook events for tests are
     identifiable. Returns { sent, to, from, message_id, bytes }.
   - Default vars are sensible: firstname="Louis", amount="60 $",
     gift_url="https://gft.link/TEST123", etc. User overrides any
     via the request body.

   Frontend (TemplateEditorPage):
   - Toolbar button "Envoyer un test" (orange) — opens a dialog.
   - Dialog has email input + subject + 7 variable inputs
     (firstname, lastname, amount, commitment_months, gift_url,
     description, expiry) with sensible defaults.
   - "Dirty" banner warning: if the user has unsaved changes, the
     test will use the LAST SAVED version (so save first to test the
     latest). Mentions explicitly in card footer.
   - On send: live notification with the message_id + byte count.
     Errors surface clearly.

Verified live in prod:
  POST /campaigns/templates/gift-email-fr/test-send → 200, message_id
  returned, ~32 KB rendered MJML→HTML output, sent from
  TARGO <support@targointernet.com> (Mailjet-validated sender).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:36:35 -04:00
louispaulb
b37270c11d feat(campaigns/editor): MJML mode — proper email-focused visual builder
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).

Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.

Backend (lib/campaigns.js):

- Add `mjml` (v5, async) dependency. Compilation happens server-side
  at SAVE time only; the send-worker reads pre-compiled .html (no
  per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
  file extension on disk: .mjml present → format='mjml', otherwise
  format='html'. Source of truth for MJML templates = .mjml file;
  .html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
  (mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
    { mjml: "<mjml>..." }  → compile to HTML, save both .mjml + .html
    { html: "..." }        → save .html only (legacy path, unchanged)
  Compilation errors return 400 with details (MJML validation soft mode).
  Both files backed up as .bak-<ts>.<ext> before overwrite.

Frontend (TemplateEditorPage.vue):

- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
  plugin. Editor's getHtml() returns MJML source (not compiled HTML);
  Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
  plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
  both formats — they're text content, format-agnostic.

API client (apps/ops/src/api/campaigns.js):

- saveTemplate(name, content, { format }) routes to the right PUT body
  shape based on format param.

Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:29:42 -04:00
louispaulb
1af8b3a029 feat(campaigns/templates): add gift-email-{fr,en}-simple variants
Flat single-table-per-section structure (max 1 level of nesting) so that
GrapesJS' preset-newsletter parser can recognize each section as an
editable component. Same brand visuals + content as the rich variants,
but: dropped the 12-logo merchant grid (heaviest part for the editor),
compacted the three info pills into one consolidated card.

Sections (top-level <table width="600">):
  1. Header logo
  2. Greeting + brand-line + offer intro
  3. Compact info card (was 3 pills)
  4. Option 1 chip
  5. Big green CTA button
  6. Prorata refund disclaimer
  7. Option 2 chip + text
  8. Optional expiry notice (Mustache conditional)
  9. Signature
 10. Contact info (outside card)
 11. Dark footer band (logo + address + copyright)

Each section is a standalone <table role="presentation" width="600">
sharing the same #ffffff background. The first and last get the rounded
border-radius, middle sections have no rounding. Result: visually one
unified card, structurally many editable blocks.

Registered both new variants in EDITABLE_TEMPLATES whitelist so the
ops UI editor picks them up. Rich variants gift-email-fr.html and
gift-email-en.html are unchanged — both styles coexist. User compares
in the editor and picks which to standardize on per campaign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:04:39 -04:00
louispaulb
bbd2b31761 feat(campaigns/templates): new opening line + logo image in dark footer
Per user feedback after seeing the rendered preview:

1. Opening line replaced:
   FR: "Tu choisis local, on veut te remercier." →
       "Comme toi, on aime les connexions stables et les relations durables."
   EN: "You went local — we want to say thanks." →
       "Just like you, we love stable connections and lasting relationships."
   The new line ties the Internet service (stable connections) to the
   relationship framing (lasting), which reads more naturally than the
   previous "we want to thank you" phrasing.

2. Dark footer band cleanup:
   • Removed the CSS-styled TARGO. wordmark (with green dot)
   • Removed the official slogan line "Services de confiance, ..."
   • Replaced with the actual TARGO logo image (img tag at 120px wide)
   The wordmark is now ALWAYS the logo image, never a text styling —
   keeps the brand mark consistent across header and footer.

TODO marker left in the HTML pointing to the white-variant logo: the
brand guide §1 specifies targo-logo-white.svg for dark backgrounds, but
we only have the green variant uploaded on Mailjet (UUID eed4d18c-...).
The green logo on the #1C1E26 Targo Dark bg is readable but not
pixel-perfect with the brand. To fix, upload the white variant via the
new /campaigns/assets/upload endpoint and swap the src in both
templates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:56:17 -04:00
louispaulb
d694d889a1 feat(campaigns/templates): replace placehold.co with real Mailjet logos for rows 2-3
User pasted the full HTML block from their Mailjet Passport editor —
extracted the 8 missing CDN URLs for the merchant grid bottom rows and
swapped them into both FR and EN templates.

Final 12-logo grid is now 100% real Mailjet-hosted assets matching the
user's brand-approved visuals (no more placehold.co rectangles):

  Row 1: Amazon, IGA, Tim Hortons, $1 Plus           (already real)
  Row 2: Pizza Pizza, Home Depot, Best Buy, Walmart  (NEW)
  Row 3: Petro-Canada, Esso, Home Hardware, Sobeys   (NEW)

URL pattern: https://xqy3m.mjt.lu/img2/xqy3m/<UUID>/content
Width normalized to 95px (consistent with row 1) instead of the source
template's 300px since our 600px-wide email card means each 25% column
is ~140px effective — 95px image fits with proper margins.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:36:51 -04:00
louispaulb
d6096fe1f8 feat(campaigns): apply real TARGO brand + auto-route FR/EN by Customer.language
Brand audit against the official guide (Feb 2026 v1.0) caught several
inconsistencies in the email template:

- Wrong primary green: was #019547, should be #00C853 (Targo Green from
  brand palette). Globally replaced.
- Wrong gradient: was #019547→#06a04d, should be 135deg #00C853→#005026
  (the official Gradient Targo from the brand). Now using Outlook-safe
  background-image + bgcolor fallback for solid green on Outlook desktop.
- Wrong contact info: facturation@targointernet.com / 514 242-1500 →
  support@targo.ca / 514 448-0773 / 1 855 888-2746 (per §11 of guide).
- Wrong website: targointernet.com + gigafibre.ca → www.targo.ca.
- Missing slogan + green dot: footer now ends with the trademark
  tagline "Services de confiance, tout-en-un, près de chez vous." with
  the obligatory green period (always FR — it's the trademark, not a
  marketing line, so stays untranslated in EN template too).
- Missing brand fonts: added Space Grotesk (display) + Plus Jakarta
  Sans (body) via Google Fonts. Wrapped in MSO conditional comments so
  Outlook desktop skips the request and falls back to Helvetica via
  the explicit font-family stack on every element.
- Wrong body bg / text colors: now #F5FAF7 (Muted) / #1B2E24
  (Foreground) per brand semantic palette.
- Wrong info-pill bg: was #f3f4f3 → #F5FAF7 (Muted).
- Added official dark footer band #1C1E26 (Targo Dark) with white
  inverted wordmark, slogan, address, copyright.

Multilang routing (FR/EN):

- lib/campaigns.js matchCustomer now fetches Customer.language
  (14k FR / 1k EN distribution confirmed on prod). Default 'fr' for
  unmatched contacts.
- New templateForLanguage(lang) helper picks gift-email-<lang>.html,
  falls back to FR. Resolves 'fr-CA' → 'fr' etc.
- sendCampaignAsync pre-loads templates per recipient with an in-memory
  cache to avoid re-reading from disk on every send.
- gift-email-en.html created — English translation of the full FR
  template, keeping the slogan in French (it's the trademark tagline).
- year variable now injected (replaces hardcoded © year).

UI (CampaignNewPage):

- New "Langue" column in the Step 2 recipient table. Shows a clickable
  chip (FR primary green / EN blue-grey) that toggles language inline,
  so a campaign manager can override the ERPNext-resolved language
  per recipient.
- Step 3 recap now shows "Répartition par langue: 145 × FR, 12 × EN"
  before confirming the send.

Spell-check:

- TemplateEditorPage HTML mode now has spellcheck="true" + dynamic
  lang attribute on the textarea, picked from the template name suffix
  (gift-email-fr → fr, gift-email-en → en). Browser's native dictionary
  flags typos in real time. AI-grade rewrites deferred to the future
  /campaigns/ai/rewrite endpoint discussed previously.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:50:56 -04:00
louispaulb
5d763f12ff feat(hub): gift-campaign module — CSV parse, customer match, async send + webhook
- lib/campaigns.js (new): full backend for the gift campaign flow.
  • Two CSV parsers: parseMapCsv handles the pipe-delimited legacy export
    with title preamble; parseGiftbitCsv auto-detects the URL column.
  • Multi-strategy customer match against ERPNext: email → phone → civic
    + postal_code on Service Location. Returns confidence score (1.0 /
    0.9 / 0.8) and match method. Addresses the 25%-match limitation of
    the legacy_delivery_id approach by fanning out to address-based
    lookup when email/phone miss.
  • Storage: JSON files at data/campaigns/<id>.json with embedded
    recipients array. Counters auto-recomputed from recipient statuses
    on every save (single source of truth).
  • Async send worker: setImmediate fire-and-forget loop, throttle
    configurable, broadcasts recipient-update events over SSE topic
    campaign:<id> for live UI progress.
  • Mailjet webhook handler at POST /campaigns/webhook: matches events
    to recipients via X-MJ-CustomID = "<campaign-id>:<recipient-index>"
    for O(1) lookup, falls back to MessageID scan if CustomID absent.
  • Template CRUD endpoints (GET/PUT /campaigns/templates/:name) with
    automatic timestamped backups before overwrite. Path-traversal
    guarded by an allow-list (only gift-email-fr editable).
  • Mustache section renderer ({{#var}}...{{/var}}) shared with the CLI.

- lib/email.js: accept opts.from override (campaign sender differs from
  default MAIL_FROM) and opts.headers passthrough (needed for the
  X-MJ-CustomID header that drives webhook → recipient correlation).
  Return the nodemailer info object on success instead of a bare bool so
  callers can capture info.messageId — legacy truthy checks still work.

- server.js: register /campaigns/* route on the hub router.

- templates/gift-email-fr.html: bundled copy of the campaign template
  inside the hub so it's deployable without scripts/ on the path. Kept
  in sync manually with scripts/campaigns/templates/gift-email-fr.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:40 -04:00
louispaulb
41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00